YouTube Summaries

← All summaries

ADSR envelopes and audio mixing in Jai

2026-08-08 Sat ⏱ 1 hr 4 min tsodingdaily

Second episode of Dimooper II (digital music looper, written in Jai with Raylib). Notes currently start and stop instantly, which clicks. Tsoding implements attack and release — the A and R of ADSR — which forces a restructure of the mixing loop and a third "released notes" sound source. The residual crunch turns out not to be the envelope at all, but the hard clamp to [-1, 1]; reading Raylib's own mixer shows it just accumulates samples without clamping.

Inverting the mixing loop

Original structure: for each note, fill the whole audio buffer. note_update took the buffer and looped internally.

Refactored to note_update returning a single float for one frame count, amplitude applied by the caller. The outer loop now iterates buffer samples, and for each sample iterates the note sources and mixes. This inversion is what makes time-varying volume possible — a note can stop mid-buffer, so the "how many notes are playing" count has to be recomputed per sample, not per buffer.

Zeroing the buffer and clamping both fold into the same per-sample loop.

Two sound sources, then three

Notes live in arrays indexed by semitone, one array per source:

  • notes_monitor — keys pressed live
  • notes_replay — recorded events being played back

Previously both were scanned in parallel and a semitone playing in either was emitted once. Now each source mixes its own note independently, so the same semitone from both sources stacks.

Tracking when a note started

A bare playing: bool carries too little information for an envelope. It becomes a struct:

Note :: struct { playing: bool; frame_stamp: int; // global frame at which it started }

He deliberately uses the global frame count rather than a wall-clock timestamp. note_press sets playing and stamps the frame; note_release clears it. Renaming playing to _playing to force compile errors at every direct access doesn't help — everything already goes through the arrays.

Attack is then trivial:

: volume = min(cast(float)(frame_count - note.frame_stamp) / ATTACK_FRAMES, 1.0)

ATTACK_FRAMES is 10,000 at 48 kHz — about a fifth of a second, deliberately long so the ramp is audible.

The release problem

Release breaks the model: on key-up playing goes false immediately, so the note vanishes from the mix and there's nothing left to fade out.

Solution is a third source, notes_released, a dynamic array (not indexed by semitone) of:

NoteReleased :: struct { frame_stamp: int; / frame at which release began frequency: float; / carried, since there's no array index to derive it from volume: float; // envelope value at the moment of release }

Storing the volume at release time matters: a key let go mid-attack must fade from wherever the ramp had reached, not from 1.0. The released note's volume is note.volume * (1 - min(elapsed/RELEASE_FRAMES, 1)).

Evicting finished notes in O(1)

note_released_done is true once elapsed exceeds RELEASE_FRAMES. Removal uses Jai's array_unordered_remove_by_index — swap with the last element and pop, O(1), fine because mix order doesn't matter.

The subtlety: iterate the array in reverse. Forward iteration with swap-remove can move an element you still intended to delete past your cursor. He verifies in a scratch program that Jai's reverse iteration (=< =) also reverses the reported index.

Eviction is folded into the same per-sample pass that counts active notes — a note can finish its release mid-buffer.

The crunch was the clamp

Attack and release work per note, but stacking notes still sounds wrong. First hypothesis: dividing amplitude by the live note count makes the gain jump the instant a second key goes down.

Instead of guessing, he reads Raylib's source (generating ETAGS over the headers and sources, then navigating with Helm — "who needs the LSP"). Path: PlaySoundPlayAudioBuffer → the audio buffers turn out to be a linked list, not a dynamic array. His charitable reading: linked lists give stable pointers into the collection, which a reallocating dynamic array cannot.

The real find is OnSendAudioDataToDevice, the miniaudio callback, and MixAudioFrames inside it. It does nothing clever — plain accumulation into the output, plus a fast sine approximation for panning, which is irrelevant to a mono synth. Critically, Raylib never clamps.

Removing the clamp to [-1, 1] eliminates the crunch. Clamping a summed sine that exceeds the range is exactly how you manufacture a square wave. Remaining crunch after that was buffer size, fixed by enlarging it.

Open ends

  • Samples exceeding [-1, 1] are apparently fine — he's unsure why he assumed otherwise.
  • Volume now grows unbounded as notes stack; wants a smooth global gain adjustment that doesn't reintroduce the jump.
  • Twitch chat suggested A-weighting for perceptually correct mixing; he's unconvinced and leaves it for a future session.
  • No decay or sustain yet — attack and release only.
  • A pure sine sounds like a flute, which he notes is why chiptune artists approximate flutes with sine waves.

Repeated safety note throughout: never test new audio code wearing headphones — one wrong float and your ears are gone.