Vibrato needs phase integration, not frequency
- YT :: https://www.youtube.com/watch?v=NhceGp6VMhg
- Original title :: Cool thing about sound waves
Episode four of Dimooper II (digital music looper, written in Jai with raylib). Tremolo goes in easily; vibrato does not, and the failure turns out to be a textbook FM-synthesis mistake: you cannot substitute a time-varying frequency into the constant-frequency sine formula. The correct construction integrates frequency into phase. The footnote after the stream derives it.
Instrument abstraction
The starting interface was a closure taking a void* plus a value, required to be periodic with period 1 (so callers using sin must multiply by 2pi themselves). He dislikes it and says so repeatedly. Attempts to parameterize the instrument by its data type, or to use Jai's #as inheritance (modelled on C++, and as annoying), both run into where-do-I-store-the-data problems: by value gives an unknown size, by pointer forces a double indirection.
Chat suggests a fat struct - one Instrument holding a tag plus every parameter, with an applied_to pointer to another instrument, so effects chain as a linked list. He adopts it, then replaces the tag with a function pointer to avoid the double jump (call, then switch). He notes the design is only stupid because he lacks information yet: "we're not doing something stupid, we're employing a stochastic process."
He also removes the instrument reference from recorded events, on the grounds that an instrument belongs to a track or channel, so events can later be moved between tracks and change instrument.
Tremolo
Tremolo is amplitude variation: take sin, map from [-1,1] to [0,1] via (x+1)/2, and multiply the child instrument's sample by it. Two bugs found live: a zero tremolo frequency yields a constant 0.5 (half volume), and the effect's rate tracked the note pitch because the instrument only received =x = time * frequency=.
The fix is an interface change: pass time and frequency separately, letting each instrument compute x itself. Tremolo then uses time alone and is independent of the note. Demonstrated at 1 Hz (countable), 10 Hz, 50 Hz (the Russian mains frequency; 60 in the US).
FFT visualizer side quest
On chat's suggestion he copy-pastes the self-contained FFT widget from his music player Swoon into Dimooper - same language, same raylib, so integration is quick, and each copy-paste surfaces friction points that make the next one easier. His maxim: "the most reusable code is the one you can copy-paste." He notes the code mutates slightly each time it spreads, virus-like, and that LLM-assisted copy-pasting now lets such code mutate automatically.
Observations from using the visualizer as a debugger:
- Square waves show a spurious very-low-frequency component that disappears as the duty cycle is raised toward 0.5 (chat: at duty 0.5 the average over a period is zero, so no DC term). Triangle waves show no such artifact.
- A 0 Hz bin is meaningless - zero frequency is no vibration, hence no sound - so rendering should start at 20 Hz.
- Chiptune spectra taper at the high end because triangle and square waves stack many overtones.
- The pipeline discards the symmetric second half of the FFT and squashes logarithmically, using a Hann window (a cosine-approximated bell).
Aside on filtering: cutting frequencies via FFT is exactly what a parametric equalizer does, and always loses information depending on resolution - "JPEG but for sound", and JPEG is itself a frequency transform. Vorbis uses FFT-style transforms; he is unsure about MP3.
Vibrato: the mistake
First attempt lerps between the neighbouring semitone frequencies using a sine of the vibrato frequency. It overshoots and grows without bound. Several reformulations (varying semitones instead of frequencies, adding a frequency_to_semitone inverse via log(f/root)/log(semitone_ratio)) all fail the same way, and he ends the stream unresolved.
Footnote: the correct derivation
Credited to Wolf Sound's "FM synthesis explained for audio programming", which names this as a common mistake.
The usual generator is sin(2*pi*f*t). Substituting a time-varying f(t) is simply wrong, because sin takes an angle - a phase - not a frequency. 2*pi*f(t) is the derivative of phase, so the phase is its integral:
: phase(t) = integral from 0 to t of 2*pi*f(tau) d tau
The familiar 2*pi*f*t is only the special case where f is constant and factors out of the integral. That coincidence hides the mechanism and breaks the moment you try to stack modulators.
With =f(t) = fn + A*sin(2*pi*fv*t)= (note frequency plus vibrato amplitude times the vibrato oscillator), the integral can be solved analytically - he uses WolframAlpha - and terms cancel to leave a cos form divided by the vibrato frequency, which he transcribes into the code. The alternative is numeric integration, accumulating the sum with =dt = 1/44100=, but that requires per-instrument state the architecture does not yet support.
The corrected version is stable rather than diverging. Notes on the result:
- Below roughly 6-8 Hz it reads as vibrato; 6 Hz sounds best.
- The bottom of the hearing range is 20 Hz. Push the vibrato frequency above it - 30 Hz, 100 Hz, 220 Hz - and it stops being perceived as vibrato and becomes a new timbre. This is the basis of FM synthesis, which he realizes he has been rediscovering.
- Tremolo behaves the same way: modulating amplitude above the hearing range also creates new timbres, despite modulating amplitude rather than frequency.
- Vibrato had to be specialized to a sine carrier (
instrument_vibrato_sine) because keeping it generic over the underlying instrument is architecturally awkward for now. - A typo passing the wrong frequency to the child instrument had broken tremolo in the meantime; fixed on air.
He closes by wondering how far modulators can be stacked - modulating the modulator, mixing wave shapes - which is trivial numerically but whose analytic form might resemble the chain rule in backpropagation.