▶ YouTube Summaries

← All summaries

Relearning miniaudio.h: sine wave, FLAC playback and a mic monitor in C

2026-10-31 Sat ⏱ 2 hr 8 min tsodingdaily

Tsoding revisits miniaudio.h, the single-header, public-domain, cross-platform audio library that raylib's audio subsystem is built on, after forgetting how to use it since the Musializer microphone feature. The goal is to drop the raylib abstraction layer and talk to the audio device directly from C. He sets up a =nob=-based build, compares compile times of TCC and Clang on the 4 MB header, initializes a playback device, discovers that the data callback runs on its own thread, generates a 440 Hz sine wave, streams a FLAC song through dr_flac from the same author, and finally wires a capture device into a playback device through a mutex-protected ring buffer to build a microphone monitor. The verdict is that miniaudio is easier and more direct than raylib's audio API, especially for non-game audio work, and he plans to use it for audio while keeping raylib for graphics.

Why miniaudio

  • raylib's audio module is a thin wrapper over miniaudio, which already supports Windows, macOS, Linux, BSD, iOS, Android and the web via Emscripten. raylib mostly integrates it into its own game-dev suite.
  • The header is about 4 MB of text and close to 100k lines, an STB-style library: include it everywhere, define MINIAUDIO_IMPLEMENTATION in one translation unit.
  • The many #ifdef branches (for example several ways to call printf depending on the MSVC version) are described as battle scars: proof the library was actually run on all those platforms. Chat puts it as "ifdefs are written in blood".
  • The library doubles as a knowledge base on obscure platform audio APIs that vendors made deliberately complex, and it is all public domain (with GitHub sponsorship available). Even raylib is not public domain.

Build setup and compile times

  • Project is plain C with nob.h as the build system, with run, debug (launch under gf2, a GDB front end) and opt toggles.
  • TCC compiles a trivial program about nine times faster than Clang. Default TCC output is roughly on par with Clang's default optimization level, which makes it good for fast iteration but not final builds.
  • TCC (about 63k lines, the same size as miniaudio) fails to compile miniaudio because of SIMD intrinsics headers, so Clang is used.
  • With miniaudio the build needs -lm. Unoptimized Clang build takes about 1.5 s, -O3 takes about 10 s, and a single file cannot be parallelized.
  • Demonstrated workaround: compile miniaudio.h separately as C (-x c, MINIAUDIO_IMPLEMENTATION, -Wno-unused-function) into an object, link it into main, and use nob_needs_rebuild to skip it when unchanged. He calls incremental builds less reliable than full rebuilds and reverts to the plain unoptimized build.

Initializing a playback device

  • Flow: ma_device_config_init(ma_device_type_playback), fill playback.format (ma_format_f32), playback.channels, sampleRate, dataCallback and pUserData, then ma_device_init, ma_device_start (the device is "sleeping" by default), and ma_device_uninit.
  • ma_device_config_init only fills a struct; no device queries happen there.
  • etags over the header makes navigating miniaudio in Emacs practical.
  • miniaudio has its own logging subsystem (ma_log_post, levels), so failed init is reported by the library.
  • Attempts to break initialization: bizarre sample rates (67694, 69) and channel counts still succeed, presumably because miniaudio resamples and converts. An out-of-range format enum crashes with a division by zero in ma_get_bytes_per_frame, since the enum indexes a lookup table. The joke: if init fails, that is how you know you are on Linux.

Callback threading

  • The data callback receives pDevice, pOutput, pInput and frameCount. In playback mode pInput is null.
  • Logging gettid() (needs _GNU_SOURCE and unistd.h) from main and the callback shows different thread IDs, so shared state needs synchronization.
  • The main thread must be kept alive; a bare while (true) burns a full core in htop, while usleep(16000) drops CPU use below 1%.

Generating a sine wave

  • Frame terminology, quoted from the docs: a frame is one sample per channel; multi-channel data is always interleaved (L, R, L, R...). Frames per callback were 1200 regardless of channel count.
  • pOutput is cast to a pointer to an array of two floats (float (*output)[2]). He uses cdecl.org and the cdecl CLI to get the declarator right, calling it "vibe coding" in English.
  • ma_channel positions are not indices, so left/right indices are managed manually.
  • The generator struct holds a frame_count; time = frame_count / sampleRate, sample = sinf(2 * MA_PI_F * 440 * t), written to both channels. MA_PI_F is provided by the library.
  • An assert on pDevice->playback.channels = 2= immediately catches a forgotten mono config, illustrating why such asserts are placed.
  • He found this easier than an earlier attempt at generating a sine wave through raylib's AudioStream on a previous stream.

Streaming a FLAC song with dr_flac

  • dr_libs (dr_flac, dr_wav, dr_mp3) come from the same author, David Reid (mackron), also public domain and STB-style.
  • drflac_open_file gives sampleRate and channels, which are fed straight into the device config. pUserData is the drflac* itself.
  • dr_flac always outputs interleaved signed 32-bit PCM, so the device format becomes ma_format_s32. Asserts verify channels, sample rate and format match.
  • drflac_read_pcm_frames_s32(pFlac, frameCount, pOutput) streams directly into the callback's output buffer, no intermediate allocation. A return smaller than requested means end of stream. The two APIs fit together because the callback asks for exactly N frames and the decoder delivers N frames.
  • A short unfinished test song played correctly on the first try and stopped cleanly at the end.
  • Seeking in dr_flac takes a frame index, unlike raylib's SeekMusicStream which takes a float in seconds and is imprecise. Precise seeking matters for RPG-style dialogue atlases packed into one audio file.

Mic monitor with a ring buffer

  • Two devices, capture and playback, share one config (ma_format_s32, sample rate 48000). A thread-ID logging callback with a label in pUserData shows three separate threads: main, capture and playback.
  • Shared state is a fixed-capacity ring buffer (capacity = one second of samples) reusing the rb_* macros from his 3D software-rendered game, extended with a new rb_pop macro (assert count > 0, decrement count, advance begin modulo capacity, wrapped in do { } while (0)).
  • Synchronization through =miniaudio='s own ma_mutex (ma_mutex_init, lock/unlock).
  • Capture callback pushes interleaved input samples into the buffer; playback callback copies rb_at(0) and pops while the buffer is non-empty.
  • First run produced only buzzing. Switching to one channel fixed it: the microphone is mono, and miniaudio silently fell back instead of failing. That silent fallback is his only complaint about the library.
  • Result is an audible slightly delayed echo of his own voice, with no feedback loop.

Takeaways

  • miniaudio does not dumb audio down: it exposes multithreaded callbacks and provides mutexes and threading primitives instead of hiding them. raylib, designed for teaching programming, sometimes treats the user like a student, which suits its purpose.
  • He prefers building his own music-stream abstractions on top of miniaudio for more control, and plans to use it directly for audio while keeping raylib for graphics.
  • Closing chat: he wants to do more machine learning streams, but only after the AI hype bubble pops.