Generalized polyphony support

I wanted to share something I’m adding to my Sapphire module base class. The nice thing is that it allows you to put one CV control input to apply to a whole bunch of audio inputs, or vice versa: one audio input can be split across multiple channels, processed slightly different ways, if you provide a CV input cable with multiple channels.

Or you can do something simple like everything is monophonic and it still works like you expect!

class SapphireModule : public Module
{
    // ... stuff omitted ...

    int numOutputChannels(int numInputs)
    {
        int nc = 0;
        for (int i = 0; i < numInputs; ++i)
            nc = std::max(nc, inputs[i].getChannels());
        return nc;
    }

    void nextChannelInputVoltage(float& voltage, int inputId, int channel)
    {
        auto& input = inputs[inputId];
        if (channel < input.getChannels())
            voltage = input.getVoltage(channel);
    }
};

This is an interesting idiom that I have come to like in certain modules (not all, by any means).

It generalizes the number of output channels to be the maximum number of channels across all input ports. Then it generalizes the concept of “normaling” voltages forward to higher channels when any given input port lacks that many channels. You end up with a for loop going through the channels like:

void process(const ProcessArgs& args) override
{
    const int nc = numOutputChannels(INPUTS_LEN);
    outputs[Y_OUTPUT].setChannels(nc);

    float vInput = 0;
    float cvSpeed = 0;
    float cvMass = 0;
    for (int c = 0; c < nc; ++c)
    {
        nextChannelInputVoltage(vInput, X_INPUT, c);
        nextChannelInputVoltage(cvSpeed, SPEED_CV_INPUT, c);
        nextChannelInputVoltage(cvMass, MASS_CV_INPUT, c);
        float vOutput = calculate(vInput, cvSpeed, cvMass);
        outputs[Y_OUTPUT].setVoltage(vOutput, c);
    }
}

I wonder, has anyone else approached it something like this?

What you describe is almost the standard polyphony behavior that VCV free uses for some modules, and I use in Venom pretty much always. Except I (and VCV) only clone the input to match the output channel count if the input is mono. If the input is poly but lacks some channels, then the missing channels are 0V.

The getPolyVoltage, geteNormalPolyVoltage, getPolyVoltagegSimd, and getNormalPolyVoltageSimd methods do exactly as I describe, so it is very simple to implement. You specify which channel you want (or start channel for simd), and the methods return the 1st channel if the input is monophonic, else it returns the specified channel.

I don’t think I would like missing channels to clone the highest channel found as you propose.

I very much like setting the output channel count equal to the max number of channels found across all inputs where polyphony makes sense. Some plugins force you to control the channel count with a specific input, or else allow you to specify which input sets the channel count. I always found that inconvenient and/or limiting. So I always use the max. I know VCV VCA does the same, and at least one more (but I can’t remember which ones)

1 Like

Mine tend to. I have to confess I don’t usually pay much attention to the case of polyphonic inputs that have less channels that the max. I think vult modules start over at channel zero and rotate again.

I think @DaveVenom’s suggestion is probably best - set them to zero.

For gates and modulation setting the channel to 0V is fine. However when dealing with pitch (V/Oct) signals 0V gives you a middle C. Often times I build chords from multiple quantized random sources and then this becomes a problem. There might be no C in the chord and multiple channels of 0V will make the C just louder. Same if I quantize again after the output, because many channels have the same pitch. So I have to split and remerge channels frequently. In that case it would be fitting to get copies of existing input channels.

In my collaboration with Andrew (PathSet), we decided on a right click menu option to dynamically change output channels based on stored input channel count in our module ChordVault. That can still give you issues (clicks, pops) depending on which modules come after the output, though.

1 Like

Interesting conversation so far!

I definitely see your point for audio input signals. I think the case where you have stereo audio input but 5 channels of some CV modulation is kinda weird. It’s ambiguous what that should even mean. If my module were sentient, it might ask, do they really want 3 channels of silence being controlled by CV?

So far in this thread we have 3 different ideas of how to handle this weird case:

  1. Normal the highest channel forward. (My approach.)
  2. Set all missing channels to zero (kinda makes sense for audio inputs).
  3. Cycle through the ports using channel % numChannels for each port’s number of channels. I didn’t know about this idea! Here the idea is, I put in one stereo signal but get N mutated stereo signals out. I can see that being helpful in some cases.

Yes, this! I should have mentioned that the module I’m working on now that inspired me to start this thread is intended as a CV processor. That is, the voltages it processes are intended as CV voltages that go somewhere else. I specifically want to use this module myself for conditioning V/OCT signals, but it could be fun for any voltage modulation. So I’m on the same page with not considering 0V a reasonable default voltage for missing channels in this particular case.

The handling of motley channel counts seems like a corner case to me, yet it may benefit from adding a right-click option to each port. I’m thinking about that now. Maybe it is possible to subclass Port and add a right-click menu option that allows you to adjust the behavior for each port in your module? It could use any of the 3 approaches we have covered here (or perhaps there are others out there).

Or as a module designer, perhaps I have an immutable enum value associated with each input ID that tells me how to handle missing channels? I’d rather not overwhelm the user with options, if my being reasonably opinionated doesn’t limit their freedom in the process.

I agree it is an odd corner case when two related poly inputs don’t match channel count. Yes, there are some unusual circumstances where the user might want any one of those behaviors, or it might be (is likely?) user error. If error, then setting to 0 is reasonable, and the user will probably want to fix the mistake. Our code cannot yet read minds, so any choice is as likely to be wrong as right. In that case I opted for the simplest option to implement that is also easy to document and understand.

But I think we all can agree that cloning mono input to match the channel count is an extremely common use case, and worthy of implementation.

3 Likes

It might be worthwhile revisiting this topic where the whole polyphonic thing was hashed out:

And then of course the terse language from the manual:

Polyphony

If your module supports polyphonic inputs or has polyphonic outputs, then it can be considered a “polyphonic module”, so add the “Polyphonic” tag to the module’s manifest. It is recommended to support up to 16 channels, which is the maximum that Rack allows.

Typically each voice in your module can be abstracted into an “engine”. The number of active engines NN should be defined by the number of channels of the “primary” input (e.g. 1 V/oct input for VCOs, audio input for filters, gate input for envelope generators, etc). All other secondary inputs with MM channels should follow these rules:

  • If monophonic (M = 1M=1), its voltage should be copied to all engines.
  • If polyphonic with enough channels (M \geq NM≥N), each channel voltage should be used in its respective engine.
  • If polyphonic but not enough channels (1 < M < N1<M<N), 0 V should be copied to out-of-bounds engines.

All of this behavior is provided by Port::getPolyVoltage(c).

Monophonic modules should handle polyphonic inputs gracefully:

  • For inputs intended to be used solely for audio, sum the voltages of all channels (e.g. with Port::getVoltageSum())
  • For inputs intended to be used for CV or hybrid audio/CV, use the first channel’s voltage (e.g. with Port::getVoltage())
3 Likes