Relearning miniaudio.h: sine wave, FLAC playback and a mic monitor in C
- YT :: https://www.youtube.com/watch?v=bW_Y5D5Mm9M
- Original title :: Last Audio Library You'll Need
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_IMPLEMENTATIONin one translation unit. - The many
#ifdefbranches (for example several ways to callprintfdepending 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.has the build system, withrun,debug(launch undergf2, a GDB front end) andopttoggles. - 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 compileminiaudiobecause of SIMD intrinsics headers, so Clang is used. - With
miniaudiothe build needs-lm. Unoptimized Clang build takes about 1.5 s,-O3takes about 10 s, and a single file cannot be parallelized. - Demonstrated workaround: compile
miniaudio.hseparately as C (-x c,MINIAUDIO_IMPLEMENTATION,-Wno-unused-function) into an object, link it intomain, and usenob_needs_rebuildto 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), fillplayback.format(ma_format_f32),playback.channels,sampleRate,dataCallbackandpUserData, thenma_device_init,ma_device_start(the device is "sleeping" by default), andma_device_uninit. ma_device_config_initonly fills a struct; no device queries happen there.etagsover the header makes navigatingminiaudioin Emacs practical.miniaudiohas 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
miniaudioresamples and converts. An out-of-range format enum crashes with a division by zero inma_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,pInputandframeCount. In playback modepInputis null. - Logging
gettid()(needs_GNU_SOURCEandunistd.h) frommainand 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 inhtop, whileusleep(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.
pOutputis cast to a pointer to an array of two floats (float (*output)[2]). He usescdecl.organd thecdeclCLI to get the declarator right, calling it "vibe coding" in English.ma_channelpositions 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_Fis provided by the library. - An
assertonpDevice->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
AudioStreamon 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_filegivessampleRateandchannels, which are fed straight into the device config.pUserDatais thedrflac*itself.dr_flacalways outputs interleaved signed 32-bit PCM, so the device format becomesma_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_flactakes a frame index, unlike raylib'sSeekMusicStreamwhich 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 inpUserDatashows 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 newrb_popmacro (assert count > 0, decrement count, advancebeginmodulo capacity, wrapped indo { } 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
miniaudiosilently 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
miniaudiodoes 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
miniaudiofor 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.