Add streaming audio sink (v0.8.0)
Introduces a Sigil-writable PCM sink wired to sokolaudio via an SPSC lockless ring buffer. Any Sigil thread may push float32 PCM frames (non-blocking); sokolaudio's audio thread drains the ring into the output buffer as a third mix stage alongside play-sound and play-music.
Public API in (sigil audio): - open-audio-stream (keys: channels, buffer-frames, volume) - push-audio-samples - audio-stream-room / -depth / -capacity / -channels / -underruns / -closed? / audio-stream? - close-audio-stream! - set-audio-stream-volume!
Under-run pads with silence and bumps a counter; over-run returns accepted frame count. Up to 4 concurrent streams. play-sound / play-music continue to work unchanged.
Tests under test/test-streaming-sink.sgl exercise push/drain parity, under-run, over-run, mono->stereo duplication, idempotent close, and per-stream volume via an internal drain shim.
CHANGELOG.md | 14 +++++
README.md | 73 ++++++++++++++++++++++
package.sgl | 8 +--
src/c/audio-stream.c | 545 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/c/audio-stream.h | 26 ++++++++
src/c/audio.c | 12 ++++
src/sigil/audio.sgl | 32 ++++++++++
test/main.c | 4 ++
test/test-streaming-sink.sgl | 144 +++++++++++++++++++++++++++++++++++++++++++
9 files changed, 854 insertions(+), 4 deletions(-)CHANGELOG.mdmodified
The format is based on [Keep a Changelog](https://keepachangelog.com/),and this project adheres to [Semantic Versioning](https://semver.org/).## [0.8.0] - 2026-04-18### Added- Streaming audio sink: `open-audio-stream`, `push-audio-samples`, `audio-stream-room`, `audio-stream-depth`, `audio-stream-capacity`, `audio-stream-channels`, `audio-stream-underruns`, `audio-stream-closed?`, `close-audio-stream!`, `set-audio-stream-volume!`, `audio-stream?`. Lockless SPSC ring lets Sigil callers feed interleaved float32 PCM from any thread to sokol_audio's audio thread; coexists additively with `play-sound` / `play-music`. See `folio topics/sigil-audio-streaming-sink-architecture`.## [0.5.0] - 2026-02-17### AddedREADME.mdmodified
sigil build```## Streaming audio sinkThree playback paths coexist in `(sigil audio)`:1. **`load-sound` + `play-sound`** — short SFX, decoded once into memory, fired from Sigil, mixed on the audio thread.2. **`play-music`** — long OGG, streamed from disk on the audio thread via stb_vorbis.3. **`open-audio-stream` + `push-audio-samples`** — caller-driven streaming sink. The caller produces interleaved float32 PCM (any thread) and pushes it into a lockless SPSC ring; the audio thread drains the ring into its output buffer. Use this for live / generative audio (motif streaming render, live-coded synths, etc.).Example — play a 440 Hz sine wave for 1 second:```scheme(import (sigil audio) (sigil math))(audio-setup)(define stream (open-audio-stream channels: 2 buffer-frames: 8192))(define sr 44100)(define pi 3.14159265358979)(define frames (* sr 1));; Build a stereo float32 bytevector with a 440 Hz sine.(define samples (let ((v (make-vector (* frames 2) 0.0))) (let loop ((i 0)) (when (< i frames) (let ((s (sin (* 2.0 pi 440.0 (/ i sr))))) (vector-set! v (* i 2) s) (vector-set! v (+ (* i 2) 1) s)) (loop (+ i 1)))) v))(define pcm (make-float-buffer samples));; Push in chunks of whatever the ring has room for.(let loop ((remaining frames) (offset-frames 0)) (when (> remaining 0) (let ((room (audio-stream-room stream))) (if (= room 0) (begin (sleep 0.005) (loop remaining offset-frames)) (let ((n (min remaining room))) (push-audio-samples stream pcm n) (loop (- remaining n) (+ offset-frames n)))))))(close-audio-stream! stream)```Notes:- Sample rate is fixed at 44100 to match sokol_audio's configured rate; callers MUST match (no resampling).- `push-audio-samples` is non-blocking and returns the frame count actually accepted — the caller decides whether to retry or drop.- Streaming sinks coexist with `play-sound` / `play-music` — they're an additional mix source, not a replacement.- Up to 4 streams may be open simultaneously.- Default `buffer-frames: 16384` (~370 ms at 44.1 kHz) is safety margin against under-run, not added latency. Live producers who want tighter reactivity can open with `buffer-frames: 2048` or smaller.See `folio topics/sigil-audio-streaming-sink-architecture`for the SPSC ring design, threading model, and under-run /over-run semantics.## LicenseBSD-3-Clausepackage.sglmodified
(package name: "sigil-audio" version: "0.7.0" version: "0.8.0" description: "Audio playback and streaming for Sigil" url: "https://codeberg.org/sigil/sigil-audio" license: "BSD-3-Clause" libraries: (list (library name: 'sigil-audio c-sources: '("src/c/sokol-audio.c" "src/c/audio.c" "src/c/ogg-encode.c") c-sources: '("src/c/sokol-audio.c" "src/c/audio.c" "src/c/audio-stream.c" "src/c/ogg-encode.c") c-include-dirs: '("vendor/sokol" "vendor/stb") native-init: "sigil__init_sigil_audio_module" ;; Platform-specific linker flags for audio + OGG Vorbis encoding description: "Build sigil-audio" steps: (list (compile-c-sources sources: '("src/c/sokol-audio.c" "src/c/audio.c" "src/c/ogg-encode.c") sources: '("src/c/sokol-audio.c" "src/c/audio.c" "src/c/audio-stream.c" "src/c/ogg-encode.c") include-dirs: '("../sigil/packages/sigil-lib/include" "../sigil/packages/sigil-lib/src" "vendor/sokol" "vendor/stb") flags: '("-std=c99" "-D_GNU_SOURCE" flags: '("-std=c11" "-D_GNU_SOURCE" "-Wno-unused-parameter" "-Wno-unused-function" "-Wno-sign-compare")) (create-static-library name: "sigil-audio") (compile-sigil-modules sources: "src/**/*.sgl"src/c/audio-stream.cadded
/* * audio-stream.c - Streaming audio sink * * SPSC lockless ring per stream. Producer = any Sigil thread (the one * that calls push-audio-samples). Consumer = sokol_audio's stream * callback thread. The ring stores interleaved float32 samples. * * Registry is a fixed-size array of active stream pointers. Open / * close mutate slots atomically; the audio callback iterates the array * under atomic acquire loads. Registry writes are rare (per open/close); * reads are per audio buffer. No mutex in the hot path. * * Capacity is rounded up to a power of two so index-wrap is a bitmask. * head/tail are free-running 64-bit counters; depth = head - tail, and * wrap of the 64-bit counter is a non-issue at audio rates. */#include "audio-stream.h"#include <stdint.h>#include <stdlib.h>#include <string.h>/* Use GCC __atomic_* builtins (sigil convention) rather than * <stdatomic.h> — zig cc's C++ include path on some distros shadows * the C stdatomic header with a C++-only variant. */#define ATM_LOAD_ACQ(p) __atomic_load_n((p), __ATOMIC_ACQUIRE)#define ATM_LOAD_REL(p) __atomic_load_n((p), __ATOMIC_RELAXED)#define ATM_STORE_REL(p, v) __atomic_store_n((p), (v), __ATOMIC_RELEASE)#define ATM_STORE_RLX(p, v) __atomic_store_n((p), (v), __ATOMIC_RELAXED)#define ATM_ADD_RLX(p, v) __atomic_fetch_add((p), (v), __ATOMIC_RELAXED)#define ATM_XCHG_ACQREL(p, v) __atomic_exchange_n((p), (v), __ATOMIC_ACQ_REL)/* CAS: returns true on success, writes observed value to *expected on failure. */static inline int atm_cas_release(void *ptr, void *expected, void *desired){ return __atomic_compare_exchange_n((void **)ptr, (void **)expected, desired, 0, __ATOMIC_RELEASE, __ATOMIC_RELAXED);}#define MAX_AUDIO_STREAMS 4#define MIN_BUFFER_FRAMES 64#define MAX_BUFFER_FRAMES (1 << 20) /* ~24 s at 44.1 kHz stereo */typedef struct AudioStream { float *ring; /* capacity_samples floats */ size_t capacity_samples;/* power of two, = frames_pow2 * channels */ size_t mask; /* capacity_samples - 1 */ int channels; /* 1 or 2 */ int buffer_frames; /* frames_pow2 (rounded up from request) */ uint64_t head; /* producer write index (in samples) */ uint64_t tail; /* consumer read index (in samples) */ int closed; uint64_t underrun_frames; float volume; /* single-writer from the producer side; * audio thread reads non-atomically. */} AudioStream;/* Registry of active streams. Entries may be NULL. */static AudioStream *g_streams[MAX_AUDIO_STREAMS];static Value stream_type_tag = SIGIL_UNDEFINED;/* ------------------------------------------------------------------ *//* Helpers *//* ------------------------------------------------------------------ */static size_t round_up_pow2(size_t n){ if (n < 2) return 1; size_t p = 1; while (p < n) p <<= 1; return p;}static void ensure_stream_type(SigilVM *vm){ if (sigil_is_undefined(stream_type_tag)) { stream_type_tag = sigil_intern_symbol(vm, "sigil-audio-stream", 18); }}static AudioStream *get_stream(SigilVM *vm, Value v){ if (!sigil_is_foreign(v)) return NULL; ensure_stream_type(vm); if (sigil_foreign_type(v) != stream_type_tag) return NULL; return (AudioStream *)sigil_foreign_data(v);}static int registry_add(AudioStream *s){ for (int i = 0; i < MAX_AUDIO_STREAMS; i++) { AudioStream *expected = NULL; if (atm_cas_release(&g_streams[i], &expected, s)) { return i; } } return -1;}static void registry_remove(AudioStream *s){ for (int i = 0; i < MAX_AUDIO_STREAMS; i++) { AudioStream *cur = ATM_LOAD_REL(&g_streams[i]); if (cur == s) { ATM_STORE_REL(&g_streams[i], (AudioStream *)NULL); return; } }}static void stream_destructor(void *data){ AudioStream *s = (AudioStream *)data; if (!s) return; ATM_STORE_REL(&s->closed, 1); registry_remove(s); free(s->ring); free(s);}/* ------------------------------------------------------------------ *//* Audio-thread mix *//* ------------------------------------------------------------------ */static void mix_one(AudioStream *s, float *out, int num_frames, int out_channels){ uint64_t head = ATM_LOAD_ACQ(&s->head); uint64_t tail = ATM_LOAD_REL(&s->tail); size_t available_samples = (size_t)(head - tail); size_t wanted_samples = (size_t)num_frames * (size_t)s->channels; size_t drain_samples = available_samples < wanted_samples ? available_samples : wanted_samples; size_t drain_frames = drain_samples / (size_t)s->channels; float vol = s->volume; if (s->channels == 2 && out_channels >= 2) { for (size_t f = 0; f < drain_frames; f++) { float l = s->ring[(tail + 0) & s->mask]; float r = s->ring[(tail + 1) & s->mask]; out[f * out_channels + 0] += l * vol; out[f * out_channels + 1] += r * vol; tail += 2; } } else if (s->channels == 1 && out_channels >= 2) { for (size_t f = 0; f < drain_frames; f++) { float v = s->ring[tail & s->mask] * vol; out[f * out_channels + 0] += v; out[f * out_channels + 1] += v; tail += 1; } } else if (s->channels == 1 && out_channels == 1) { for (size_t f = 0; f < drain_frames; f++) { out[f] += s->ring[tail & s->mask] * vol; tail += 1; } } else { /* channels == 2, out == 1: downmix */ for (size_t f = 0; f < drain_frames; f++) { float l = s->ring[(tail + 0) & s->mask]; float r = s->ring[(tail + 1) & s->mask]; out[f] += (l + r) * 0.5f * vol; tail += 2; } } ATM_STORE_REL(&s->tail, tail); if (drain_frames < (size_t)num_frames) { uint64_t missing = (uint64_t)(num_frames - (int)drain_frames); ATM_ADD_RLX(&s->underrun_frames, missing); }}void sigil_audio_stream_mix_all(float *out, int num_frames, int out_channels){ for (int i = 0; i < MAX_AUDIO_STREAMS; i++) { AudioStream *s = ATM_LOAD_ACQ(&g_streams[i]); if (!s) continue; if (ATM_LOAD_ACQ(&s->closed)) continue; mix_one(s, out, num_frames, out_channels); }}void sigil_audio_stream_shutdown_all(void){ for (int i = 0; i < MAX_AUDIO_STREAMS; i++) { AudioStream *s = ATM_LOAD_REL(&g_streams[i]); if (s) { ATM_STORE_REL(&s->closed, 1); ATM_STORE_REL(&g_streams[i], (AudioStream *)NULL); } }}/* ------------------------------------------------------------------ *//* Natives *//* ------------------------------------------------------------------ *//* * (%open-audio-stream channels buffer-frames volume) -> <audio-stream> or #f * * Sigil wrapper applies keyword args. Sample rate is fixed at sokol_audio's * configured rate (44100) — caller must match. */static Value native_open_audio_stream(SigilVM *vm, int argc, Value *args){ if (argc < 3) { sigil__vm_set_error(vm, SIGIL_ERR_ARITY, "%open-audio-stream: channels buffer-frames volume"); return SIGIL_FALSE; } int channels = (int)sigil_as_fixnum(args[0]); int buffer_frames = (int)sigil_as_fixnum(args[1]); float volume = (float)sigil_as_flonum(args[2]); if (channels != 1 && channels != 2) { sigil__vm_set_error(vm, SIGIL_ERR_TYPE, "open-audio-stream: channels must be 1 or 2"); return SIGIL_FALSE; } if (buffer_frames < MIN_BUFFER_FRAMES) buffer_frames = MIN_BUFFER_FRAMES; if (buffer_frames > MAX_BUFFER_FRAMES) buffer_frames = MAX_BUFFER_FRAMES; size_t frames_pow2 = round_up_pow2((size_t)buffer_frames); size_t cap_samples = frames_pow2 * (size_t)channels; AudioStream *s = calloc(1, sizeof(*s)); if (!s) return SIGIL_FALSE; s->ring = calloc(cap_samples, sizeof(float)); if (!s->ring) { free(s); return SIGIL_FALSE; } s->capacity_samples = cap_samples; s->mask = cap_samples - 1; s->channels = channels; s->buffer_frames = (int)frames_pow2; s->volume = volume; s->head = 0; s->tail = 0; s->closed = 0; s->underrun_frames = 0; if (registry_add(s) < 0) { free(s->ring); free(s); sigil__vm_set_error(vm, SIGIL_ERR_RUNTIME, "open-audio-stream: too many active streams"); return SIGIL_FALSE; } ensure_stream_type(vm); return sigil_make_foreign(vm, stream_type_tag, s, stream_destructor, sizeof(AudioStream) + cap_samples * sizeof(float));}/* * (audio-stream? obj) -> boolean */static Value native_stream_p(SigilVM *vm, int argc, Value *args){ (void)argc; return get_stream(vm, args[0]) ? SIGIL_TRUE : SIGIL_FALSE;}/* * (push-audio-samples stream pcm-bv frames) -> fixnum * * Non-blocking. Returns frames actually copied (may be < frames). * Returns 0 on a closed stream. */static Value native_push_audio_samples(SigilVM *vm, int argc, Value *args){ if (argc < 3) { sigil__vm_set_error(vm, SIGIL_ERR_ARITY, "push-audio-samples: stream pcm-bv frames"); return sigil_fixnum(0); } AudioStream *s = get_stream(vm, args[0]); if (!s) { sigil__vm_set_error(vm, SIGIL_ERR_TYPE, "push-audio-samples: expected audio-stream"); return sigil_fixnum(0); } if (!sigil_is_bytevector(args[1])) { sigil__vm_set_error(vm, SIGIL_ERR_TYPE, "push-audio-samples: expected bytevector"); return sigil_fixnum(0); } int64_t req_frames_i = sigil_as_fixnum(args[2]); if (req_frames_i < 0) req_frames_i = 0; if (ATM_LOAD_ACQ(&s->closed)) { return sigil_fixnum(0); } size_t req_frames = (size_t)req_frames_i; size_t req_samples = req_frames * (size_t)s->channels; /* Bytevector must hold at least req_samples * 4 bytes. */ size_t bv_bytes = sigil_bytevector_length(args[1]); if (req_samples * sizeof(float) > bv_bytes) { req_samples = bv_bytes / sizeof(float); req_samples -= req_samples % (size_t)s->channels; /* frame-align */ req_frames = req_samples / (size_t)s->channels; } uint64_t head = ATM_LOAD_REL(&s->head); uint64_t tail = ATM_LOAD_ACQ(&s->tail); size_t in_use = (size_t)(head - tail); size_t room = s->capacity_samples - in_use; size_t write_samples = req_samples < room ? req_samples : room; /* Whole-frame alignment. */ write_samples -= write_samples % (size_t)s->channels; const float *src = (const float *)sigil_bytevector_data(args[1]); for (size_t i = 0; i < write_samples; i++) { s->ring[(head + i) & s->mask] = src[i]; } ATM_STORE_REL(&s->head, head + write_samples); return sigil_fixnum((int64_t)(write_samples / (size_t)s->channels));}/* (audio-stream-room stream) -> fixnum — frames free */static Value native_stream_room(SigilVM *vm, int argc, Value *args){ (void)argc; AudioStream *s = get_stream(vm, args[0]); if (!s) return sigil_fixnum(0); uint64_t head = ATM_LOAD_REL(&s->head); uint64_t tail = ATM_LOAD_ACQ(&s->tail); size_t in_use = (size_t)(head - tail); size_t room_samples = s->capacity_samples - in_use; return sigil_fixnum((int64_t)(room_samples / (size_t)s->channels));}/* (audio-stream-depth stream) -> fixnum — frames queued */static Value native_stream_depth(SigilVM *vm, int argc, Value *args){ (void)argc; AudioStream *s = get_stream(vm, args[0]); if (!s) return sigil_fixnum(0); uint64_t head = ATM_LOAD_ACQ(&s->head); uint64_t tail = ATM_LOAD_ACQ(&s->tail); size_t in_use = (size_t)(head - tail); return sigil_fixnum((int64_t)(in_use / (size_t)s->channels));}/* (audio-stream-capacity stream) -> fixnum — total ring size in frames */static Value native_stream_capacity(SigilVM *vm, int argc, Value *args){ (void)argc; AudioStream *s = get_stream(vm, args[0]); if (!s) return sigil_fixnum(0); return sigil_fixnum((int64_t)s->buffer_frames);}/* (audio-stream-channels stream) -> fixnum */static Value native_stream_channels(SigilVM *vm, int argc, Value *args){ (void)argc; AudioStream *s = get_stream(vm, args[0]); if (!s) return sigil_fixnum(0); return sigil_fixnum((int64_t)s->channels);}/* (audio-stream-underruns stream) -> fixnum — cumulative underrun frames */static Value native_stream_underruns(SigilVM *vm, int argc, Value *args){ (void)argc; AudioStream *s = get_stream(vm, args[0]); if (!s) return sigil_fixnum(0); uint64_t u = ATM_LOAD_REL(&s->underrun_frames); if (u > (uint64_t)SIGIL_FIXNUM_MAX) u = (uint64_t)SIGIL_FIXNUM_MAX; return sigil_fixnum((int64_t)u);}/* (audio-stream-closed? stream) -> boolean */static Value native_stream_closed_p(SigilVM *vm, int argc, Value *args){ (void)argc; AudioStream *s = get_stream(vm, args[0]); if (!s) return SIGIL_TRUE; return ATM_LOAD_ACQ(&s->closed) ? SIGIL_TRUE : SIGIL_FALSE;}/* (close-audio-stream! stream) — unregister; destructor frees on GC */static Value native_close_audio_stream(SigilVM *vm, int argc, Value *args){ (void)argc; AudioStream *s = get_stream(vm, args[0]); if (!s) return SIGIL_NIL; if (ATM_XCHG_ACQREL(&s->closed, 1)) { return SIGIL_NIL; } registry_remove(s); return SIGIL_NIL;}/* * (%audio-stream-drain-for-test out-bv num-frames out-channels) -> fixnum * * Test-only shim: simulates the audio thread by running the mix pass on * a Sigil-provided float32 bytevector. Returns num-frames (nothing fancy * — the caller checks sample values). Treats the bytevector as * zero-initialized each call; the caller is responsible for clearing. * Not exported from the Sigil library — %-prefix signals internal. */static Value native_drain_for_test(SigilVM *vm, int argc, Value *args){ if (argc < 3 || !sigil_is_bytevector(args[0])) { sigil__vm_set_error(vm, SIGIL_ERR_TYPE, "%audio-stream-drain-for-test: out-bv frames channels"); return sigil_fixnum(0); } float *out = (float *)sigil_bytevector_data(args[0]); size_t bv_bytes = sigil_bytevector_length(args[0]); int num_frames = (int)sigil_as_fixnum(args[1]); int out_channels = (int)sigil_as_fixnum(args[2]); size_t need_bytes = (size_t)num_frames * (size_t)out_channels * sizeof(float); if (need_bytes > bv_bytes) { sigil__vm_set_error(vm, SIGIL_ERR_RUNTIME, "%audio-stream-drain-for-test: bytevector too small"); return sigil_fixnum(0); } memset(out, 0, need_bytes); sigil_audio_stream_mix_all(out, num_frames, out_channels); return sigil_fixnum((int64_t)num_frames);}/* * (%bv-f32-ref bv byte-offset) -> flonum * * Test-only: read a float32 from a bytevector at byte-offset, widened to * a flonum. Sigil stdlib doesn't ship a float32 bytevector accessor yet. */static Value native_bv_f32_ref(SigilVM *vm, int argc, Value *args){ if (argc < 2 || !sigil_is_bytevector(args[0])) { sigil__vm_set_error(vm, SIGIL_ERR_TYPE, "%bv-f32-ref: bv offset"); return sigil_flonum(0.0); } int64_t off = sigil_as_fixnum(args[1]); size_t bv_bytes = sigil_bytevector_length(args[0]); if (off < 0 || (size_t)off + sizeof(float) > bv_bytes) { sigil__vm_set_error(vm, SIGIL_ERR_RUNTIME, "%bv-f32-ref: offset out of bounds"); return sigil_flonum(0.0); } float f; memcpy(&f, sigil_bytevector_data(args[0]) + off, sizeof(f)); return sigil_flonum((double)f);}/* (set-audio-stream-volume! stream volume) */static Value native_set_stream_volume(SigilVM *vm, int argc, Value *args){ if (argc < 2) { sigil__vm_set_error(vm, SIGIL_ERR_ARITY, "set-audio-stream-volume!: stream volume"); return SIGIL_NIL; } AudioStream *s = get_stream(vm, args[0]); if (!s) { sigil__vm_set_error(vm, SIGIL_ERR_TYPE, "set-audio-stream-volume!: expected audio-stream"); return SIGIL_NIL; } float v = (float)sigil_as_flonum(args[1]); if (v < 0.0f) v = 0.0f; if (v > 1.0f) v = 1.0f; s->volume = v; /* audio thread reads non-atomically; single-writer producer */ return SIGIL_NIL;}/* ------------------------------------------------------------------ *//* Module registration *//* ------------------------------------------------------------------ */void sigil__register_audio_stream(SigilVM *vm){ sigil_module_register_native(vm, "%open-audio-stream", native_open_audio_stream, SIGIL_ARITY_EXACT(3), "Open a streaming audio sink (internal)"); sigil_module_register_native(vm, "audio-stream?", native_stream_p, SIGIL_ARITY_EXACT(1), "Check if object is an audio-stream"); sigil_module_register_native(vm, "push-audio-samples", native_push_audio_samples, SIGIL_ARITY_EXACT(3),Showing the first 500 of 546 diff lines for this file. This diff is INCOMPLETE; read the file or clone the repository for the rest.
src/c/audio-stream.hadded
/* * audio-stream.h - Streaming audio sink for sigil-audio * * SPSC lockless ring buffer from any Sigil thread (producer) to * sokol_audio's stream callback thread (consumer). See * folio topics/sigil-audio-streaming-sink-architecture. */#ifndef SIGIL_AUDIO_STREAM_H#define SIGIL_AUDIO_STREAM_H#include "sigil/sigil.h"/* Called from the audio callback (audio.c) after play-sound / play-music * mixing. Drains each registered stream into the output buffer, padding * with silence on under-run. Stream mix is additive. */void sigil_audio_stream_mix_all(float *out, int num_frames, int out_channels);/* Register natives into the current module; called from audio.c init. */void sigil__register_audio_stream(SigilVM *vm);/* Called from audio-shutdown to stop and detach all streams. Frees no * memory — stream handles stay alive via GC, but are marked closed. */void sigil_audio_stream_shutdown_all(void);#endifsrc/c/audio.cmodified
/* stb_vorbis for OGG decoding (implementation in stb_impl.c) */#include "stb_vorbis.c"/* Streaming audio sink (SPSC ring) */#include "audio-stream.h"/* ============================================================ * CONSTANTS * ============================================================ */ } } /* Mix streaming sinks (SPSC ring sources). Additive, silence on under-run. */ sigil_audio_stream_mix_all(buffer, num_frames, num_channels); /* Clamp output */ for (int i = 0; i < num_frames * num_channels; i++) { if (buffer[i] > 1.0f) buffer[i] = 1.0f; (void)vm; (void)argc; (void)args; if (g_audio_initialized) { /* Detach any active streaming sinks before tearing down the device */ sigil_audio_stream_shutdown_all(); /* Stop music */ if (g_music.vorbis) { stb_vorbis_close(g_music.vorbis); /* OGG encoding */ sigil__register_ogg_encode(vm); /* Streaming sink */ sigil__register_audio_stream(vm); sigil_end_module(vm);}src/sigil/audio.sglmodified
(export ;; OGG encoding (Scheme wrapper with keyword args) write-ogg ;; Streaming sink (Scheme wrapper with keyword args; rest native) open-audio-stream ;; Playlist utilities (Scheme-defined) wait-music-end play-playlist) (quality 0.4))) (%write-ogg path sample-buffer sample-rate channels quality)) ;; ============================================================ ;; Streaming Audio Sink ;; ============================================================ ;;; Open a streaming audio sink. ;;; ;;; Returns an `<audio-stream>` handle. Push interleaved float32 PCM ;;; with `push-audio-samples`; the audio thread drains the ring and ;;; mixes the result alongside `play-sound` / `play-music`. ;;; ;;; Keywords: ;;; channels: 1 (mono, duplicated to stereo out) or 2 (stereo). ;;; Default 2. ;;; buffer-frames: Ring capacity in frames. Rounded up to a power ;;; of two. Default 16384 (~370ms at 44.1 kHz) — ;;; this is safety margin, not latency. Shrink it ;;; (e.g. 2048) for live-coding reactivity. ;;; volume: Per-stream gain, 0.0-1.0. Default 1.0. ;;; ;;; The stream's sample rate MUST match sokol_audio's configured ;;; rate (44100 today). No resampling is performed. ;;; ;;; Audio must be set up (`audio-setup`) first. Up to 4 streams may ;;; be open simultaneously. (define (open-audio-stream (keys: (channels 2) (buffer-frames 16384) (volume 1.0))) (%open-audio-stream channels buffer-frames volume)) ;; ============================================================ ;; Playlist Utilities ;; ============================================================test/main.cmodified
} /* Add load paths */ const char *stdlib_override = getenv("SIGIL_STDLIB_LIB"); if (stdlib_override) { sigil_vm_add_load_path(vm, stdlib_override); } sigil_vm_add_load_path(vm, "deps/sigil-stdlib/src"); sigil_vm_add_load_path(vm, "build/release/lib"); sigil_vm_add_load_path(vm, "build/dev/lib");test/test-streaming-sink.sgladded
;;; Test streaming audio sink;;;;;; Exercises the SPSC ring buffer without opening a real sokol_audio;;; device. %audio-stream-drain-for-test stands in for the audio;;; callback. Verifies push/consume parity, under-run, and over-run.(import (sigil core) (sigil audio))(define failures 0)(define (check label actual expected) (if (equal? actual expected) (begin (display " ok: ") (display label) (newline)) (begin (display " FAIL: ") (display label) (newline) (display " expected: ") (display expected) (newline) (display " actual: ") (display actual) (newline) (set! failures (+ failures 1)))))(define (check-pred label actual pred) (if (pred actual) (begin (display " ok: ") (display label) (newline)) (begin (display " FAIL: ") (display label) (newline) (display " actual: ") (display actual) (newline) (set! failures (+ failures 1)))));; Build a stereo float32 bytevector of N frames where every sample == v.(define (const-frames n-frames channels v) (let* ((n-samples (* n-frames channels)) (bv (make-float-buffer (make-vector n-samples v)))) bv));; ------------------------------------------------------------;; Test 1: basic push / drain / depth accounting;; ------------------------------------------------------------(display "Test 1: basic push/drain (stereo, 2048-frame ring)\n")(let ((s (open-audio-stream channels: 2 buffer-frames: 2048))) (check "audio-stream? true" (audio-stream? s) #t) (check "depth initially zero" (audio-stream-depth s) 0) ;; capacity rounds up to next power of two (check-pred "capacity >= 2048" (audio-stream-capacity s) (lambda (c) (>= c 2048))) (check "channels == 2" (audio-stream-channels s) 2) (let* ((n 512) (bv (const-frames n 2 0.25)) (accepted (push-audio-samples s bv n))) (check "all 512 frames accepted" accepted n) (check "depth == 512" (audio-stream-depth s) 512) ;; Drain 256 frames via the test shim. (let ((out (make-bytevector (* 256 2 4) 0))) (%audio-stream-drain-for-test out 256 2) (check "depth after drain 256" (audio-stream-depth s) 256) ;; First float in out should be ~0.25 (our stream is the only ;; source). (check-pred "first sample == 0.25" (%bv-f32-ref out 0) (lambda (v) (< (abs (- v 0.25)) 1e-6)))) ;; Drain the remaining 256 plus 128 of silence — under-run. (let ((out (make-bytevector (* 384 2 4) 0))) (%audio-stream-drain-for-test out 384 2) (check "depth zero after overflow drain" (audio-stream-depth s) 0) (check-pred "underruns >= 128" (audio-stream-underruns s) (lambda (u) (>= u 128))) ;; First sample still from the stream (0.25), tail sample silence. (check-pred "first sample still 0.25" (%bv-f32-ref out 0) (lambda (v) (< (abs (- v 0.25)) 1e-6))) (check-pred "last sample is silence" (%bv-f32-ref out (* (- 384 1) 2 4)) (lambda (v) (< (abs v) 1e-6))))) (close-audio-stream! s) (check "audio-stream-closed? after close" (audio-stream-closed? s) #t) (check "push on closed returns 0" (push-audio-samples s (const-frames 64 2 0.1) 64) 0));; ------------------------------------------------------------;; Test 2: over-run — push more than capacity;; ------------------------------------------------------------(display "\nTest 2: over-run (push exceeds ring capacity)\n")(let* ((s (open-audio-stream channels: 2 buffer-frames: 1024)) (cap (audio-stream-capacity s)) (big (* cap 2)) ;; ask to push 2x capacity (bv (const-frames big 2 0.5)) (accepted (push-audio-samples s bv big))) (check-pred "accepted <= capacity" accepted (lambda (a) (<= a cap))) (check-pred "accepted > 0" accepted (lambda (a) (> a 0))) (check "depth == accepted" (audio-stream-depth s) accepted) (check "room == 0 (full)" (audio-stream-room s) 0) ;; A second push while full should accept 0. (check "second push returns 0" (push-audio-samples s (const-frames 128 2 0.9) 128) 0) (close-audio-stream! s));; ------------------------------------------------------------;; Test 3: mono stream duplicates to stereo out;; ------------------------------------------------------------(display "\nTest 3: mono stream duplicated into stereo out\n")(let ((s (open-audio-stream channels: 1 buffer-frames: 512))) (check "channels == 1" (audio-stream-channels s) 1) (let* ((n 128) (bv (const-frames n 1 0.5)) (accepted (push-audio-samples s bv n))) (check "mono push accepted 128" accepted n)) (let ((out (make-bytevector (* 128 2 4) 0))) (%audio-stream-drain-for-test out 128 2) (check-pred "L channel == 0.5" (%bv-f32-ref out 0) (lambda (v) (< (abs (- v 0.5)) 1e-6))) (check-pred "R channel == 0.5" (%bv-f32-ref out 4) (lambda (v) (< (abs (- v 0.5)) 1e-6)))) (close-audio-stream! s));; ------------------------------------------------------------;; Test 4: close is idempotent and depth/room safe on closed;; ------------------------------------------------------------(display "\nTest 4: close idempotent\n")(let ((s (open-audio-stream channels: 2 buffer-frames: 256))) (close-audio-stream! s) (close-audio-stream! s) ;; no crash (check "closed? after double close" (audio-stream-closed? s) #t));; ------------------------------------------------------------;; Test 5: volume set reflected in drained samples;; ------------------------------------------------------------(display "\nTest 5: set-audio-stream-volume!\n")(let ((s (open-audio-stream channels: 2 buffer-frames: 512 volume: 1.0))) (set-audio-stream-volume! s 0.5) (push-audio-samples s (const-frames 64 2 1.0) 64) (let ((out (make-bytevector (* 64 2 4) 0))) (%audio-stream-drain-for-test out 64 2) (check-pred "sample scaled by 0.5" (%bv-f32-ref out 0) (lambda (v) (< (abs (- v 0.5)) 1e-6)))) (close-audio-stream! s))(newline)(if (= failures 0) (display "All streaming-sink tests passed!\n") (error "streaming-sink test failures" failures))