YouTube Summaries

← All summaries

Building a music looper in Jai with raylib

2026-08-04 Tue ⏱ 3 hr 47 min tsodingdaily

Tsoding revives Dimooper, a MIDI-style music looper he wrote in Rust about ten years ago with SDL2 and an external QSynth/JACK synth, rebuilding it from scratch in Jai with raylib. Over the session he gets a real-time sine synthesizer running on raylib's audio stream API, maps a QWERTY row to piano notes via equal-temperament math, and lands a first working quantized record/replay loop synced to a bar grid.

Project and stack

The old setup depended on an external synthesizer, which he calls fragile; this time the synth is built into the application, with eventual plans for customizable instrument envelopes, sample-based drums, and possibly SoundFont support. He rejects Rust ("grew out of it") and C/C++ ("stinky boomer languages", C++ "more of a compilation target"), settling on Jai plus raylib, reusing the raylib Jai bindings he maintains for his music player Swoon. Working title: "demooper2 electric bugaloo". He notes raylib is beginner-friendly but you hit its limits fast and should expect to hack its source — as he has for Swoon.

Early raylib setup: 800x600 window, 60 FPS target, 0x808080 background. He shows a compact Jai hex-color trick — take a pointer to a hex constant and dereference it as a Color — where little-endianness makes the byte order come out as R, G, B, A. He verifies audio at all by loading one-shot sounds from an old Ada game project before touching synthesis, and explains raylib's Sound (fully in memory) versus streamed audio, where LoadAudioStream gives a bare stateless stream intended for procedural generation.

Audio streaming and the sine wave

Stream config: 44100 Hz, mono, 32-bit float samples — he reads raylib/miniaudio source to learn that only 8- and 16-bit are real options and anything else falls back to float32, and prefers floats for later DSP math. The processor callback is a Jai lambda marked #c_call, requiring an explicit push_context before logging or calling sin(), since the C-call context has no implicit Jai context.

The naive generator — global frame counter, =time = frame_count / sample_rate=, sin(2*pi*freq*time) — came out sounding square, and rather than keep guessing he opened raylib's own audio stream example. That surfaced idioms he did not know: SetAudioStreamBufferSizeDefault, and the IsAudioStreamProcessed + UpdateAudioStream pattern of filling a buffer yourself each frame and pushing it. He switches to that design. Smaller buffers measurably cut input-to-sound latency (down to 64 samples) at the cost of quality — a candidate user setting later. He also digresses into miniaudio, the ~30k-line C library under raylib's audio, which he likes ("stb on steroids").

Notes, keys, and mixing

Standard 12-tone equal temperament: =frequency = root * 2^(semitone/12)= with A4 = 440 Hz, wrapped in a note(semitone_offset: float) -> float helper. Before wiring input he just stepped the semitone once per second off floor(time) to hear the formula work. Keyboard mapping treats the Z/S/X/D/C/V/G/B/H/N/J/M row as an interleaved white/black piano layout, with parallel compile-time arrays for keys and notes sized to match, the array index doubling as the semitone offset. He demos fifths, major triads, and deliberate dissonance.

Simultaneous notes are summed sample by sample. Unnormalized summation overflowed and glitched; the fix is scaling amplitude by 1/notes_count plus a per-sample clamp(-1, 1) (using Jai's pointer-taking clamp that mutates in place), rather than true normalization. He then strips state out of the Note struct step by step — dropping frequency, then frame_count, passing each as a synth-update parameter and incrementing the frame count externally by buffer size — until a note is essentially just an "is playing" flag. Proper ADSR envelopes to fix harsh note cutoff are acknowledged and deferred.

Beats, bars, quantization

BPM gives =beat_sec = 60 / BPM=; a bar is 4 beats; bar_quant (tried at 16/32/64) sets subdivisions, so =quant_sec = bar_sec / bar_quant=. Beat crossings are detected by comparing previous versus current fmod values (Jai's math library calls modulo "cycling", which he finds a very Jonathan Blow naming choice) rather than trusting absolute float time, because accumulated drift made exact boundary tests unreliable. The bar renders as a horizontal strip split by vertical lines per beat with a white marker sweeping left to right over bar_sec, plus a corner circle indicating idle/waiting/recording — closely reproducing the original Dimooper, whose circle he once drew pixel by pixel in SDL because he did not know how to draw circles.

Recording and replay

Events are MIDI-shaped: Event { time_stamp: quant, semitone: int, start: bool } in a dynamic array. A three-state machine — replay, wait_until_end_of_bar, record — means pressing record does not start immediately; it waits for the beat-crossing detector to confirm the bar boundary, then clears prior events and zeroes both beat and quant timers. A bug caught live: notes already held at the instant recording begins were missed, fixed by injecting synthetic start events for them.

For replay, the recording length in bars comes from the last event's timestamp divided by bar_quant, rounded up; the playback position is current_quant mod (bars * bar_quant), and matching events fire each frame. The linear scan over all events per frame is knowingly wasteful — a lower-bound binary search is the right answer (he detours into implementing one in Swoon to skip hidden songs) — but the event count is tiny so far.

The design problem he only half-solves: live keyboard input and event playback shared one note-state array, so playing along with a replaying loop corrupts state. He splits it into notes_monitor and notes_replay, combining both when counting active notes for amplitude scaling, but calls it ad hoc — it should generalize to arbitrary note-state layers. Scattered switch-on-state logic is likewise flagged as refactoring debt.

Bugs and asides

Bugs hit: the square-sounding sine (solved by reading raylib's example); glitches from switching to buffer summation without zeroing the buffer each frame; a Jai precision error casting 64-bit floor() results down to 32-bit samples; quant tracking wrong on the first recorded bar until both timers were reset at record start; missed already-held notes.

Asides: dislike of the Rust ecosystem's graphical-library-of-the-month churn; appreciation for Jai ergonomics (lambdas, defer, pointer/value clamp overloads, .count); gripes about raylib's undocumented sampleSize semantics and texture-flip-via-negative-width hack, balanced against its hackability. He jokes that anticipated AI-generated pull request spam is why he is not accepting contributions this time, and considers Codeberg or self-hosting over GitHub. English music terminology gave him trouble mid-stream (he thinks in Russian — такт for bar). Deferred to later episodes: ADSR envelopes, a centralized state machine, binary-search event lookup, multi-bar visualization, post-recording quantization correction for notes played early or late, gamepad analog input for continuous pitch and effects, and an instrument/envelope editor.