robbert-vdh / nih-plug

Rust VST3 and CLAP plugin framework and plugins - because everything is better when you do it yourself
ISC License
1.45k stars 129 forks source link

Buffr Glitch is slightly off-tune on high notes #138

Open jamesWalker55 opened 1 month ago

jamesWalker55 commented 1 month ago

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:

https://github.com/robbert-vdh/nih-plug/assets/30405220/6f6b5b04-2e71-4b4a-aa93-c73134d22a71

arseniiv commented 1 month ago

(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.

robbert-vdh commented 1 month ago

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.

arseniiv commented 1 month ago

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:

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.