robbert-vdh / nih-plug

Rust VST3 and CLAP plugin framework and plugins - because everything is better when you do it yourself

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Buffr Glitch is slightly off-tune on high notes

jamesWalker55 opened this issue · comments

I'm testing out the Buffr Glitch plugin, however I noticed that for higher notes, the pitch seems to become increasingly off-pitch, here's a recording:

2024-04-05.12-26-28.mp4

(Just some musings of an observer.)

@jamesWalker55 that’s indeed what should happen if the buffer size is always constant. The actual frequency would be a subharmonic of the sample rate (a buffer of 100 samples at sample rate 48 kHz will be played back at 480 Hz, a buffer of 101 samples at ≈475 Hz). So for small N (and high frequencies) there is not much precision to have.

Maybe increased precision can be achieved by playing either the full buffer or the buffer minus one sample at different times to get an average frequency matching what one needs, though the sound would be worse because there’d be no perfect repetition.

The buffer's length is indeed rounded up, which shouldn't be noticeable in most cases, but when the buffer starts becoming smaller than a couple hundred samples that will indeed start adding up. Periodically adding an additional sample to keep the average buffer duration closer to the ideal fractional length is a great idea! That's kinda similar to dithering, but it can be done deterministically. I'll do that when I find time to work on this again.

Yep that can be done deterministically and I can write out when exactly, right here for the future when it’ll be needed. It ends up somewhat like Bresenham's line drawing algorithm.

Let F₀ be the target frequency expressed as a multiple of the sample rate (so, 0 < F₀ < 1). Actually the period P₀ = 1/F₀ is more useful. We’re looking at two buffer sizes of interest, S = floor(P₀) samples and L = ceil(P₀) samples, for S ≠ L. When s buffers of S samples and ℓ buffers of L samples were played back, it means we had (s + ℓ) periods in the time of (s S + ℓ L) samples which gives the actual period P = (s S + ℓ L)/(s + ℓ) = S + ℓ/(s + ℓ).

As it’s the intent to keep P as close to P₀ as possible, we look at the inequality between them, P₀ <?> S + ℓ/(s + ℓ) which is equivalent to (P₀ − S) (s + ℓ) − ℓ <?> 0. Scheduling a smaller buffer will make LHS into (P₀ − S) (s + ℓ + 1) − ℓ, making it larger, and scheduling a larger one will make it into (P₀ − S) (s + ℓ + 1) − ℓ − 1, making it smaller because 0 < (P₀ − S) < 1.

So when LHS < 0, we schedule a smaller buffer and when LHS > 0, we schedule a larger one (and when LHS = 0, an arbitrary choice works). Now simplify what’s actually calculated each step:

  • let Δ := P₀ − S = frac(P₀) = P₀ mod 1
  • s += 1 actually adds Δ to LHS
  • ℓ += 1 actually adds Δ − 1 to LHS

Now the algorithm becomes very simple (I’ll use pseudo-C syntax):

// init either at the very start or after each note on:
lhs = 0;  // can be arbitrary in range [0; 1)

// init after each note on, note frequency being `f`:
period = sampleRate / f;
smallerBufferSize = floor(period);
delta = period - smallerBufferSize;

// while filling in an outgoing buffer when a note is on:
while (...) {
    longerBufferNext = (lhs > 0);
    lhs += delta;
    if (longerBufferNext) {
        lhs -= 1;
    }
    addMoreSamples(longerBufferNext);
}

Fortunately this works even if P₀ is precisely an integer and all buffers should be the same size every time. (Then Δ = 0 and a shorter size is always picked, which is the true size in this case).

Hopefully there’s no errors and this was useful. Also I suggest adding a checkbox into the plugin’s parameters to retain the current behavior because precise buffer repetition is a useful feature to have.

EDIT. Interestingly enough, the pattern of smaller and larger buffers which get emitted, should make an infinite Sturmian word associated with P₀. For a moment I feared continued fractions would be needed to generate it, but this algorithm still exists. Continued fractions might be needed for absolute precision which isn’t the case here.

Revisiting this, I now see you’re processing sample by sample. That makes some things easier (than if it was block-in block-out). I’m probably going to try making a PR though I’m quite lazy (or better said, totally anxious) with installing all the build/testing stuff and working them out; so hopefully it doesn’t need so much code as to make it invalid in hardly-debuggable way. I also don’t know Rust well. Still I think it’d be nice to try.

Almost done, actually, if I’m not missing any other files to change.

The implemented version is a little different from my pseudocode here because I reoriented to use longer buffer size as reference when, for example, the period in samples is exact integer (practically impossible given floats, but one should be ready for that anyway).