Commit542998e6Recorded20 Sep 2026Repositorysigil-audio
Review fixes: ALSA buffer parity, log newlines, a device-drain test
Message
From the adversarial review of the branch:
- miniaudio sizes a buffer as period x periods (3 by default), so the ALSA fallback had a 6144-frame buffer against sokol_audio's 2048; the device now asks for two 1024-frame periods (2048 on ALSA, as before; a 1024-frame tlength on PulseAudio).
- miniaudio's log lines mostly carry no newline; the callback adds one, so a backend's error no longer runs into the next line. The message for the Null backend no longer claims no library was found when a library loaded and its server was unreachable.
- test/test-device-drain.sgl opens the real device, pushes half a second into a stream and checks the depth fell after 350 ms: the proof that frames leave the ring on the device's thread, which no other test gave (a push into an empty ring succeeds with no device at all). Sabotaged by stubbing the stream mix out of audio_callback: the depth stayed at 22050 and the test failed.
- The library record lists only the files every target compiles; the docs say libpulse.so is tried before libpulse.so.0 and mark the ALSA route as untested; the remaining sokol_audio comments are gone.
Changed
CHANGELOG.md | 5 +++--
README.md | 6 +++---
manifest.scm | 2 +-
package.sgl | 6 +++++-
src/c/audio-stream.c | 4 ++--
src/c/audio-stream.h | 2 +-
src/c/miniaudio-impl.c | 18 ++++++++++++++----
src/sigil/audio.sgl | 4 ++--
src/sigil/audio/sink.sgl | 15 ++++++++-------
test/dl-search-test.sh | 4 +++-
test/test-device-drain.sgl | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++
test/test-streaming-sink.sgl | 2 +-
12 files changed, 96 insertions(+), 25 deletions(-)Diff
CHANGELOG.mdmodified
@@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
11
12
- **The playback device is miniaudio 0.11.25** (vendored, unpatched, 13
device layer only), replacing sokol_audio. Backends are loaded at run−14
time: on Linux PulseAudio (`libpulse.so.0`; pipewire-pulse on a+14
time: on Linux PulseAudio (`libpulse.so`, then `.so.0`; pipewire-pulse on a 15
PipeWire desktop), then ALSA, then JACK; WASAPI on Windows; CoreAudio 16
on macOS. **Nothing audio-related is linked any more**: `-lasound`, 17
`AudioToolbox` and `ole32`/`winmm`/`ksuser` are gone from@@ -30,7 +30,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
30
- `SIGIL_AUDIO_VERBOSE=1` prints the backend, the device, the format 31
conversion miniaudio applies and every resolver hit. 32
- `manifest.scm` no longer needs `alsa-lib` to build; it lists `pulseaudio`−33
for the tests that open a device.+33
for the test that opens a device (`test/test-device-drain.sgl`, which+34
proves the device thread drains a stream). 35
36
### Removed 37
README.mdmodified
@@ -16,7 +16,7 @@ None to build. The playback device is [miniaudio](https://miniaud.io)
16
0.11.25, vendored under `vendor/miniaudio` (device layer only; see 17
`vendor/miniaudio/VENDOR.md`), and it loads its backend at run time: 18
−19
- Linux: `libpulse.so.0` first (PulseAudio, which on a PipeWire desktop is+19
- Linux: `libpulse.so` / `libpulse.so.0` first (PulseAudio, which on a PipeWire desktop is 20
pipewire-pulse; `PULSE_SINK` picks the sink), then `libasound.so.2` 21
(ALSA), then `libjack.so.0`. Nothing is linked: `-lasound` is gone. 22
- Windows: WASAPI through `ole32` / `mmdevapi` / `avrt`, loaded at run@@ -31,8 +31,8 @@ of both. With no backend library anywhere miniaudio's Null backend runs
31
the mixer in silence and one line on stderr says so. 32
`SIGIL_AUDIO_VERBOSE=1` prints the backend, the device and every 33
resolver hit. A run manifest for a game on Guix adds `pulseaudio` (the−34
client library, 187 MiB of closure) or `alsa-lib` (3 MiB, but the ALSA−35
route needs the system's ALSA configuration to reach PipeWire) to+34
client library, 187 MiB of closure) or `alsa-lib` (3 MiB; untested here, and the ALSA+35
route would go through the machine's ALSA configuration to reach PipeWire) to 36
sigil-desktop's `guix/run-wayland.scm`. 37
38
OGG Vorbis encoding/decoding is **vendored** (libogg 1.3.6 +manifest.scmmodified
@@ -7,7 +7,7 @@
7
;; (src/c/dl-search.c) searches: `pulseaudio` for libpulse.so.0 (the first 8
;; backend miniaudio tries; on a PipeWire desktop that is pipewire-pulse, 9
;; and PULSE_SINK routes it), or `alsa-lib` for libasound.so.2 (the ALSA−10
;; backend; going through PipeWire's ALSA plugin needs the system ALSA+10
;; backend; untested here: going through PipeWire's ALSA plugin needs the machine's ALSA 11
;; configuration, so prefer pulseaudio in a pure shell). 12
13
(specifications->manifestpackage.sglmodified
@@ -60,7 +60,11 @@
60
libraries: (list 61
(library 62
name: 'sigil-audio−63
c-sources: '("src/c/miniaudio-impl.c" "src/c/dl-search.c" "src/c/audio.c" "src/c/audio-stream.c" "src/c/ogg-encode.c")+63
;; sigil-build reads this list only as a "has native code" test and+64
;; for the include dirs; the build task below compiles the real,+65
;; target-dependent list (dl-search.c on Linux only). Keep it to+66
;; files every target compiles.+67
c-sources: '("src/c/miniaudio-impl.c" "src/c/audio.c" "src/c/audio-stream.c" "src/c/ogg-encode.c") 68
;; vendor/ogg/include + vendor/vorbis/include expose the public 69
;; ogg/vorbis headers consumed by ogg-encode.c. vendor/vorbis/lib 70
;; is needed because libvorbis internal .c files include theirsrc/c/audio-stream.cmodified
@@ -2,7 +2,7 @@
2
* audio-stream.c - Streaming audio sink 3
* 4
* SPSC lockless ring per stream. Producer = any Sigil thread (the one−5
* that calls push-audio-samples). Consumer = sokol_audio's stream+5
* that calls push-audio-samples). Consumer = the playback device's stream 6
* callback thread. The ring stores interleaved float32 samples. 7
* 8
* Registry is a fixed-size array of active stream pointers. Open /@@ -205,7 +205,7 @@ void sigil_audio_stream_shutdown_all(void)
205
/* 206
* (%open-audio-stream channels buffer-frames volume) -> <audio-stream> or #f 207
*−208
* Sigil wrapper applies keyword args. Sample rate is fixed at sokol_audio's+208
* Sigil wrapper applies keyword args. Sample rate is fixed at the device's 209
* configured rate (44100) — caller must match. 210
*/ 211
static Value native_open_audio_stream(SigilVM *vm, int argc, Value *args)src/c/audio-stream.hmodified
@@ -2,7 +2,7 @@
2
* audio-stream.h - Streaming audio sink for sigil-audio 3
* 4
* SPSC lockless ring buffer from any Sigil thread (producer) to−5
* sokol_audio's stream callback thread (consumer). See+5
* the playback device's callback thread (miniaudio; consumer). See 6
* folio topics/sigil-audio-streaming-sink-architecture. 7
*/ 8
src/c/miniaudio-impl.cmodified
@@ -69,7 +69,10 @@ static void log_callback(void *user, ma_uint32 level, const char *message)
69
{ 70
(void)user; 71
if (level <= MA_LOG_LEVEL_WARNING || getenv("SIGIL_AUDIO_VERBOSE")) {−72
fprintf(stderr, "sigil-audio: miniaudio %s: %s", ma_log_level_to_string(level), message);+72
/* most of miniaudio's messages carry no newline */+73
size_t n = strlen(message);+74
fprintf(stderr, "sigil-audio: miniaudio %s: %s%s", ma_log_level_to_string(level), message,+75
(n > 0 && message[n - 1] == '\n') ? "" : "\n"); 76
} 77
} 78
@@ -113,8 +116,9 @@ int sigil_audio_device_open(int sample_rate, int channels, int buffer_frames,
116
* backend consumes frames in silence. Say so once, out loud, so a 117
* silent game is not a mystery; the trace under SIGIL_AUDIO_VERBOSE 118
* lists every directory the resolver tried. */−116
fprintf(stderr, "sigil-audio: no audio backend library found "−117
"(libpulse.so.0, libasound.so.2, libjack.so.0 on Linux); running silent. "+119
fprintf(stderr, "sigil-audio: no audio backend could be used "+120
"(no libpulse.so.0 / libasound.so.2 / libjack.so.0 on Linux, or no server behind one; "+121
"the lines above say which); running silent. " 122
"Put one in a profile the resolver searches or in SIGIL_LIBRARY_PATH.\n"); 123
} 124
@@ -123,7 +127,13 @@ int sigil_audio_device_open(int sample_rate, int channels, int buffer_frames,
127
dc.playback.format = ma_format_f32; 128
dc.playback.channels = (ma_uint32)channels; 129
dc.sampleRate = (ma_uint32)sample_rate;−126
dc.periodSizeInFrames = (ma_uint32)buffer_frames;+130
/* sokol_audio's ALSA buffer was buffer_frames (2048) in total; miniaudio+131
* sizes a buffer as periodSizeInFrames x periods, so halve the period+132
* and use two of them to keep the same ~46 ms on ALSA. On PulseAudio+133
* tlength follows the period (1024 frames, 23 ms) and the server's own+134
* buffering sits behind it. */+135
dc.periodSizeInFrames = (ma_uint32)(buffer_frames / 2);+136
dc.periods = 2; 137
dc.dataCallback = data_callback; 138
/* the stream callback writes every frame (it clears first) */ 139
dc.noPreSilencedOutputBuffer = MA_TRUE;src/sigil/audio.sglmodified
@@ -1,6 +1,6 @@
1
;;; (sigil audio) - Audio Module 2
;;;−3
;;; Provides audio playback capabilities via sokol_audio.+3
;;; Provides audio playback through the device layer in miniaudio-impl.c. 4
;;; Uses stb_vorbis for OGG audio decoding. 5
;;; 6
;;; Sound effects are loaded entirely into memory for low-latency playback.@@ -96,7 +96,7 @@
96
;;; (e.g. 2048) for live-coding reactivity. 97
;;; volume: Per-stream gain, 0.0-1.0. Default 1.0. 98
;;;−99
;;; The stream's sample rate MUST match sokol_audio's configured+99
;;; The stream's sample rate MUST match the device's configured 100
;;; rate (44100 today). No resampling is performed. 101
;;; 102
;;; Audio must be set up (`audio-setup`) first. Up to 4 streams maysrc/sigil/audio/sink.sglmodified
@@ -4,7 +4,8 @@
4
;;; for pushing interleaved float32 PCM into a device, and selects its 5
;;; backend with `cond-expand`: 6
;;;−7
;;; - native (else): the sokol_audio SPSC ring living in (sigil audio)+7
;;; - native (else): the SPSC ring living in (sigil audio), drained by the+8
;;; playback device (miniaudio) 9
;;; - wasm: the WebAudio bridge in (sigil browser audio-sink), 10
;;; provided by the sigil-wasm-audio package 11
;;;@@ -16,9 +17,9 @@
17
;;; 18
;;; WebAudio requires an AudioContext that starts suspended and must be 19
;;; resumed from a user gesture (autoplay policy). The context ops model−19
;;; that. On native, sokol owns a single always-running device, so the−20
;;; context ops are thin shims (open initializes sokol; resume is a no-op;−21
;;; the sample rate is sokol's fixed 44100). They exist so the SAME source+20
;;; that. On native, the device layer owns a single always-running device, so the+21
;;; context ops are thin shims (open initializes the device; resume is a no-op;+22
;;; the sample rate is a fixed 44100). They exist so the SAME source 23
;;; compiles and runs on both targets behind this facade. 24
;;; 25
;;; ```scheme@@ -69,8 +70,8 @@
70
(else 71
(begin 72
−72
;;; Open the audio context. On native this initializes sokol_audio−73
;;; and returns an opaque non-#f handle (sokol owns a single device,+73
;;; Open the audio context. On native this initializes the playback device+74
;;; and returns an opaque non-#f handle (the device layer owns one device, 75
;;; so the handle is a sentinel). On wasm this creates a suspended 76
;;; AudioContext and returns its id. 77
(define (audio-context-open)@@ -82,7 +83,7 @@
83
(define (audio-context-resume ctx) 84
#t) 85
−85
;;; The context sample rate in Hz. Sokol runs at a fixed 44100; the+86
;;; The context sample rate in Hz. The device runs at a fixed 44100; the 87
;;; sink ring assumes the same rate (no resampling). 88
(define (audio-context-sample-rate ctx) 89
44100)test/dl-search-test.shmodified
@@ -1,5 +1,7 @@
1
#!/bin/sh−2
# dl-search-test.sh - the interposed dlopen against fake Guix and Nix profiles.+2
# dl-search-test.sh - the dlopen resolver against fake Guix and Nix profiles+3
# (sigil-desktop: the interposed dlopen; sigil-audio: the named resolver,+4
# the probe calls whichever the -D configuration below selects). 5
# 6
# Builds a probe from src/c/dl-search.c, a throwaway shared object, and a 7
# temp HOME holding an empty ~/.guix-home/profile/lib and atest/test-device-drain.sgladded
@@ -0,0 +1,53 @@
+1
;;; Test the playback device: the device thread drains a streaming sink.+2
;;;+3
;;; Opens the real device (miniaudio picks the backend; with no backend+4
;;; library it is the Null backend, which still consumes frames in real+5
;;; time), pushes half a second of silence into a stream, waits, and checks+6
;;; the depth fell. That is the one thing test-streaming-sink.sgl cannot+7
;;; show: that frames leave the ring through audio_callback on the device's+8
;;; thread, not only that they go in. SIGIL_AUDIO_VERBOSE=1 names the+9
;;; backend that did it. Run it with PULSE_SINK pointing at a null sink.+10
+11
(import (sigil core)+12
(sigil time)+13
(sigil audio))+14
+15
(define failures 0)+16
+17
(define (check label actual expected)+18
(if (equal? actual expected)+19
(begin (display " ok: ") (display label) (newline))+20
(begin+21
(display " FAIL: ") (display label) (newline)+22
(display " expected: ") (display expected) (newline)+23
(display " actual: ") (display actual) (newline)+24
(set! failures (+ failures 1)))))+25
+26
(define (busy-wait seconds)+27
(let ((t0 (current-second)))+28
(let loop () (when (< (- (current-second) t0) seconds) (loop)))))+29
+30
(audio-setup)+31
(check "audio initialized" (audio-initialized?) #t)+32
+33
(let* ((frames 22050) ; half a second at 44100+34
(s (open-audio-stream channels: 2 buffer-frames: 32768))+35
(bv (make-bytevector (* frames 2 4) 0))+36
(accepted (push-audio-samples s bv frames)))+37
(check "the push was accepted whole" accepted frames)+38
(check "depth is the push" (audio-stream-depth s) frames)+39
(busy-wait 0.35)+40
(let ((after (audio-stream-depth s)))+41
(display " depth after 350 ms: ") (display after) (newline)+42
(check "the device thread drained at least a quarter second"+43
(< after (- frames 11025)) #t)+44
(check "and not more than was pushed" (>= after 0) #t))+45
(close-audio-stream! s))+46
+47
(audio-shutdown)+48
(check "audio shut down" (audio-initialized?) #f)+49
+50
(if (= failures 0)+51
(begin (display "All device-drain tests passed!") (newline))+52
(begin (display failures) (display " device-drain test(s) FAILED") (newline)+53
(exit 1)))test/test-streaming-sink.sglmodified
@@ -1,6 +1,6 @@
1
;;; Test streaming audio sink 2
;;;−3
;;; Exercises the SPSC ring buffer without opening a real sokol_audio+3
;;; Exercises the SPSC ring buffer without opening a real playback 4
;;; device. %audio-stream-drain-for-test stands in for the audio 5
;;; callback. Verifies push/consume parity, under-run, and over-run. 6