YouTube Summaries

← All summaries

Pluggable instruments: square and sawtooth waves in Dimooper

2026-08-10 Mon ⏱ 1 hr 19 min tsodingdaily

Third episode of Dimooper II, a digital music looper written in Jai. The looper had only a hardcoded sine wave, so the session generalises the sound generation into a pluggable Instrument abstraction: a periodic function of period one plus a payload of parameters. On top of that abstraction square and sawtooth waves are implemented, wired into note press/release and into the event recording, and the square wave's pulse width is mapped to a gamepad analog stick so it can be swept while a note sustains.

The instrument abstraction

Before the session the synthesis code had two kinds of notes: a playing note (semitone, frame counter, playing status) and a released note (semitone, frame counter, plus the moment and volume at release so it can be faded out). Neither carried an instrument, because the instrument was hardcoded — each note's update plugged the current time into a sine formula and returned one sample out of the 48000 played per second. Tsoding notes in passing that "sample" and "frame" are used interchangeably in audio code and that the terminology is a mess everywhere, not just here.

The idea is to make that formula pluggable: an instrument is a function pointer taking x and returning y. Since sine has period 2π and other periodic waveforms have other natural periods, the interface fixes a convention — every instrument is a periodic function with period one, measuring angle in turns (a full circle equals 1). Sine adapts by multiplying its argument by 2π internally. The payoff is immediate: inside an instrument you can reduce x to its period by taking the fractional part, no fmod bookkeeping.

Instruments also need parameters — a square wave wants a pulse-width parameter p controlling where the wave flips from +1 to -1. Jai has no closures, so the classic answer is a void pointer of extra data passed alongside. Instead of leaving the pointer loose, the instrument becomes a struct parameterized over its data type: a function field plus the data stored inline, with the function receiving a pointer into that same struct. An instrument_run helper takes a pointer to the instrument and an x, and calls the function with the instrument's own data — so the instrument variable is mentioned once instead of twice. Sine is parameterized with void, square with a float. Jai lacks closures but has lambdas, so instruments can be declared as global values with the function written inline.

Square and sawtooth

The square wave is short: take the fractional part of x, return 1 if it is below p, otherwise -1. The sawtooth uses p as the position of the peak: from 0 to p, lerp from -1 to 1 using x/p; from p to 1, lerp from 1 back to -1 using (x-p)/(1-p). Jai's lerp takes a, b and the interpolation factor, which matches the usage directly.

The code is written first and compiled afterwards. Tsoding is explicit about the method: lay down the logic as near-pseudocode, ignore syntax and the LSP's complaints until the thought is finished, then loop through the compiler errors. He frames it as a counterpoint to the claim that LLMs free you from caring about syntax — he does not use LLMs and does not care about syntax either, because syntax errors are mechanically fixable and solving the problem comes first.

Type system friction

Jai requires a polymorphic struct to be fully specified even when only a pointer to it is stored, so notes and events hold a *Instrument(void), and a pointer to Instrument(float) must be cast to it. Jai does not give covariance here, so the assignment is forced explicitly. It is safe because the pointer field is machine-word sized regardless of the parameter and the calling code never touches the data itself. Tsoding mentions Jai's #as mechanism — a struct embedded via using plus #as gives implicit conversion from the outer type to the inner one, mechanically the same thing C++ inheritance does on the machine level, minus the OOP ideology — as a possibly more elegant future fix. He also walks through covariance and contravariance to explain what the compiler is refusing.

Another fix forced by the refactor: all the phase computations move to 64-bit floats. With 32-bit floats the argument fed into sine grows with the note's duration, and larger floats have fewer bits for the fractional part, so a long-held note gradually turns choppy and "deep fried" — the same mechanism as Minecraft's Far Lands. 64 bits does not remove the problem, only pushes it far away.

Wiring instruments through the system

note_press now takes an instrument pointer and stores it on the note; note_release copies it onto the released note so the fade-out uses the same waveform. A semi-global instrument_current lives in main outside the event loop, and recorded events capture it too — which means the instrument must be a stable, long-lived object in memory, since events hold a pointer to it.

A latent bug surfaces during this refactor: the backspace modifier that shifts the octave is applied when producing sound but not when recording the event or drawing the ring animation, so the recorded semitone is wrong. The straightforward fix (separating the key's semitone from the actual played semitone) breaks the notes monitor, which is an array indexed by semitone and would overflow for shifted-down notes. Rather than redesign that structure mid-session, Tsoding reverts the fix and leaves a TODO — some bugs are gateways to worse bugs.

A long detour goes to Linux audio: after a system update the audio server had spawned a pile of phantom output devices and nothing was audible until the right one was found in the mixer. He notes the update fear this creates, only half-jokingly blaming LLM-written code for software rotting in random places.

Gamepad as a continuous controller

To modulate the square wave's pulse width live, Raylib's gamepad API is used. There is no way to ask how many gamepads exist — you start at index 0 and probe IsGamepadAvailable in a loop, which is what the Raylib example does too. Enumerating reveals the laptop touchpad and the microphone showing up as gamepads, with the actual Logitech F310 (reported under a different name) at index 1. The triggers turn out to be exposed as axes rather than buttons, and are not continuous on this pad; the sticks are, with six axes total (two per stick plus the triggers) and a small non-zero resting value that looks like stick drift.

The stick axis, which ranges from -1 to 1, is remapped to 0..1 and lerped into a pulse-width range, then assigned straight into the instrument's data field each frame. Sweeping the stick while holding a bass note audibly thins and widens the pulse. This is the direction of the whole project: accept input from any device — keyboard, mouse, gamepad, MIDI, drawing tablet — and let the user assemble their own instrument.

Results and asides

Square waves give an immediately recognisable chiptune sound, harsh because approximating a square from pure frequencies needs many overtones. Sawtooth with p at 0.5 sounds surprisingly close to a sine; a p near zero gives the classic saw, but literally zero divides by zero and produces a horrible noise, worked around by using a very small value instead of adding a guard. Every time the waveform math changes, the headphones come off first, on the principle that you never know where the mistake is.

Sound samples as instruments were considered and deferred to a separate task, since there are at least two distinct modes for them: short clips mapped to keys and pitch-shifted per note, and a full recorded track (beatboxing instead of drums) looping underneath.

Aside from the synthesis work, the stream shows off the existing looper: octave-shift modifiers on a modifier column, a metronome, recording, and a dissipating-ring animation on each note event. Tsoding remarks that a QWERTY keyboard held like a violin is a genuinely convenient instrument and wonders why it is not more common.