Commite3a26aa1Recorded18 Apr 2026Repositorysigil-audio
Merge branch 'feat/streaming-sink' — streaming audio sink (v0.9.0)
Changed
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(-)Diff
A merge. Shown against its first parent, so this is the effect of merging rather than the work of the branch.
CHANGELOG.mdmodified
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
5
The format is based on [Keep a Changelog](https://keepachangelog.com/), 6
and this project adheres to [Semantic Versioning](https://semver.org/). 7
+8
## [0.9.0] - 2026-04-18+9
+10
### Added+11
+12
- Streaming audio sink: `open-audio-stream`, `push-audio-samples`,+13
`audio-stream-room`, `audio-stream-depth`, `audio-stream-capacity`,+14
`audio-stream-channels`, `audio-stream-underruns`,+15
`audio-stream-closed?`, `close-audio-stream!`,+16
`set-audio-stream-volume!`, `audio-stream?`. Lockless SPSC ring+17
lets Sigil callers feed interleaved float32 PCM from any thread+18
to sokol_audio's audio thread; coexists additively with+19
`play-sound` / `play-music`. See+20
`folio topics/sigil-audio-streaming-sink-architecture`.+21
22
## [0.5.0] - 2026-02-17 23
24
### AddedREADME.mdmodified
@@ -26,6 +26,79 @@ sigil deps install
26
sigil build 27
``` 28
+29
## Streaming audio sink+30
+31
Three playback paths coexist in `(sigil audio)`:+32
+33
1. **`load-sound` + `play-sound`** — short SFX, decoded once into+34
memory, fired from Sigil, mixed on the audio thread.+35
2. **`play-music`** — long OGG, streamed from disk on the audio+36
thread via stb_vorbis.+37
3. **`open-audio-stream` + `push-audio-samples`** — caller-driven+38
streaming sink. The caller produces interleaved float32 PCM+39
(any thread) and pushes it into a lockless SPSC ring; the audio+40
thread drains the ring into its output buffer. Use this for+41
live / generative audio (motif streaming render, live-coded+42
synths, etc.).+43
+44
Example — play a 440 Hz sine wave for 1 second:+45
+46
```scheme+47
(import (sigil audio) (sigil math))+48
+49
(audio-setup)+50
+51
(define stream (open-audio-stream channels: 2+52
buffer-frames: 8192))+53
+54
(define sr 44100)+55
(define pi 3.14159265358979)+56
(define frames (* sr 1))+57
+58
;; Build a stereo float32 bytevector with a 440 Hz sine.+59
(define samples+60
(let ((v (make-vector (* frames 2) 0.0)))+61
(let loop ((i 0))+62
(when (< i frames)+63
(let ((s (sin (* 2.0 pi 440.0 (/ i sr)))))+64
(vector-set! v (* i 2) s)+65
(vector-set! v (+ (* i 2) 1) s))+66
(loop (+ i 1))))+67
v))+68
+69
(define pcm (make-float-buffer samples))+70
+71
;; Push in chunks of whatever the ring has room for.+72
(let loop ((remaining frames) (offset-frames 0))+73
(when (> remaining 0)+74
(let ((room (audio-stream-room stream)))+75
(if (= room 0)+76
(begin (sleep 0.005) (loop remaining offset-frames))+77
(let ((n (min remaining room)))+78
(push-audio-samples stream pcm n)+79
(loop (- remaining n) (+ offset-frames n)))))))+80
+81
(close-audio-stream! stream)+82
```+83
+84
Notes:+85
- Sample rate is fixed at 44100 to match sokol_audio's+86
configured rate; callers MUST match (no resampling).+87
- `push-audio-samples` is non-blocking and returns the frame+88
count actually accepted — the caller decides whether to+89
retry or drop.+90
- Streaming sinks coexist with `play-sound` / `play-music` —+91
they're an additional mix source, not a replacement.+92
- Up to 4 streams may be open simultaneously.+93
- Default `buffer-frames: 16384` (~370 ms at 44.1 kHz) is+94
safety margin against under-run, not added latency. Live+95
producers who want tighter reactivity can open with+96
`buffer-frames: 2048` or smaller.+97
+98
See `folio topics/sigil-audio-streaming-sink-architecture`+99
for the SPSC ring design, threading model, and under-run /+100
over-run semantics.+101
102
## License 103
104
BSD-3-Clausepackage.sglmodified
@@ -5,7 +5,7 @@
5
6
(package 7
name: "sigil-audio"−8
version: "0.7.0"+8
version: "0.9.0" 9
description: "Audio playback and streaming for Sigil" 10
url: "https://codeberg.org/sigil/sigil-audio" 11
license: "BSD-3-Clause"@@ -29,7 +29,7 @@
29
libraries: (list 30
(library 31
name: 'sigil-audio−32
c-sources: '("src/c/sokol-audio.c" "src/c/audio.c" "src/c/ogg-encode.c")+32
c-sources: '("src/c/sokol-audio.c" "src/c/audio.c" "src/c/audio-stream.c" "src/c/ogg-encode.c") 33
c-include-dirs: '("vendor/sokol" "vendor/stb") 34
native-init: "sigil__init_sigil_audio_module" 35
;; Platform-specific linker flags for audio + OGG Vorbis encoding@@ -44,12 +44,12 @@
44
description: "Build sigil-audio" 45
steps: (list 46
(compile-c-sources−47
sources: '("src/c/sokol-audio.c" "src/c/audio.c" "src/c/ogg-encode.c")+47
sources: '("src/c/sokol-audio.c" "src/c/audio.c" "src/c/audio-stream.c" "src/c/ogg-encode.c") 48
include-dirs: '("../sigil/packages/sigil-lib/include" 49
"../sigil/packages/sigil-lib/src" 50
"vendor/sokol" 51
"vendor/stb")−52
flags: '("-std=c99" "-D_GNU_SOURCE"+52
flags: '("-std=c11" "-D_GNU_SOURCE" 53
"-Wno-unused-parameter" "-Wno-unused-function" "-Wno-sign-compare")) 54
(create-static-library name: "sigil-audio") 55
(compile-sigil-modules sources: "src/**/*.sgl"src/c/audio-stream.cadded
@@ -0,0 +1,545 @@
+1
/*+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+6
* callback thread. The ring stores interleaved float32 samples.+7
*+8
* Registry is a fixed-size array of active stream pointers. Open /+9
* close mutate slots atomically; the audio callback iterates the array+10
* under atomic acquire loads. Registry writes are rare (per open/close);+11
* reads are per audio buffer. No mutex in the hot path.+12
*+13
* Capacity is rounded up to a power of two so index-wrap is a bitmask.+14
* head/tail are free-running 64-bit counters; depth = head - tail, and+15
* wrap of the 64-bit counter is a non-issue at audio rates.+16
*/+17
+18
#include "audio-stream.h"+19
+20
#include <stdint.h>+21
#include <stdlib.h>+22
#include <string.h>+23
+24
/* Use GCC __atomic_* builtins (sigil convention) rather than+25
* <stdatomic.h> — zig cc's C++ include path on some distros shadows+26
* the C stdatomic header with a C++-only variant. */+27
#define ATM_LOAD_ACQ(p) __atomic_load_n((p), __ATOMIC_ACQUIRE)+28
#define ATM_LOAD_REL(p) __atomic_load_n((p), __ATOMIC_RELAXED)+29
#define ATM_STORE_REL(p, v) __atomic_store_n((p), (v), __ATOMIC_RELEASE)+30
#define ATM_STORE_RLX(p, v) __atomic_store_n((p), (v), __ATOMIC_RELAXED)+31
#define ATM_ADD_RLX(p, v) __atomic_fetch_add((p), (v), __ATOMIC_RELAXED)+32
#define ATM_XCHG_ACQREL(p, v) __atomic_exchange_n((p), (v), __ATOMIC_ACQ_REL)+33
/* CAS: returns true on success, writes observed value to *expected on failure. */+34
static inline int atm_cas_release(void *ptr, void *expected, void *desired)+35
{+36
return __atomic_compare_exchange_n((void **)ptr, (void **)expected,+37
desired, 0,+38
__ATOMIC_RELEASE, __ATOMIC_RELAXED);+39
}+40
+41
#define MAX_AUDIO_STREAMS 4+42
#define MIN_BUFFER_FRAMES 64+43
#define MAX_BUFFER_FRAMES (1 << 20) /* ~24 s at 44.1 kHz stereo */+44
+45
typedef struct AudioStream {+46
float *ring; /* capacity_samples floats */+47
size_t capacity_samples;/* power of two, = frames_pow2 * channels */+48
size_t mask; /* capacity_samples - 1 */+49
int channels; /* 1 or 2 */+50
int buffer_frames; /* frames_pow2 (rounded up from request) */+51
+52
uint64_t head; /* producer write index (in samples) */+53
uint64_t tail; /* consumer read index (in samples) */+54
int closed;+55
uint64_t underrun_frames;+56
+57
float volume; /* single-writer from the producer side;+58
* audio thread reads non-atomically. */+59
} AudioStream;+60
+61
/* Registry of active streams. Entries may be NULL. */+62
static AudioStream *g_streams[MAX_AUDIO_STREAMS];+63
+64
static Value stream_type_tag = SIGIL_UNDEFINED;+65
+66
/* ------------------------------------------------------------------ */+67
/* Helpers */+68
/* ------------------------------------------------------------------ */+69
+70
static size_t round_up_pow2(size_t n)+71
{+72
if (n < 2) return 1;+73
size_t p = 1;+74
while (p < n) p <<= 1;+75
return p;+76
}+77
+78
static void ensure_stream_type(SigilVM *vm)+79
{+80
if (sigil_is_undefined(stream_type_tag)) {+81
stream_type_tag = sigil_intern_symbol(vm, "sigil-audio-stream", 18);+82
}+83
}+84
+85
static AudioStream *get_stream(SigilVM *vm, Value v)+86
{+87
if (!sigil_is_foreign(v)) return NULL;+88
ensure_stream_type(vm);+89
if (sigil_foreign_type(v) != stream_type_tag) return NULL;+90
return (AudioStream *)sigil_foreign_data(v);+91
}+92
+93
static int registry_add(AudioStream *s)+94
{+95
for (int i = 0; i < MAX_AUDIO_STREAMS; i++) {+96
AudioStream *expected = NULL;+97
if (atm_cas_release(&g_streams[i], &expected, s)) {+98
return i;+99
}+100
}+101
return -1;+102
}+103
+104
static void registry_remove(AudioStream *s)+105
{+106
for (int i = 0; i < MAX_AUDIO_STREAMS; i++) {+107
AudioStream *cur = ATM_LOAD_REL(&g_streams[i]);+108
if (cur == s) {+109
ATM_STORE_REL(&g_streams[i], (AudioStream *)NULL);+110
return;+111
}+112
}+113
}+114
+115
static void stream_destructor(void *data)+116
{+117
AudioStream *s = (AudioStream *)data;+118
if (!s) return;+119
ATM_STORE_REL(&s->closed, 1);+120
registry_remove(s);+121
free(s->ring);+122
free(s);+123
}+124
+125
/* ------------------------------------------------------------------ */+126
/* Audio-thread mix */+127
/* ------------------------------------------------------------------ */+128
+129
static void mix_one(AudioStream *s, float *out, int num_frames,+130
int out_channels)+131
{+132
uint64_t head = ATM_LOAD_ACQ(&s->head);+133
uint64_t tail = ATM_LOAD_REL(&s->tail);+134
+135
size_t available_samples = (size_t)(head - tail);+136
size_t wanted_samples = (size_t)num_frames * (size_t)s->channels;+137
size_t drain_samples = available_samples < wanted_samples+138
? available_samples : wanted_samples;+139
size_t drain_frames = drain_samples / (size_t)s->channels;+140
+141
float vol = s->volume;+142
+143
if (s->channels == 2 && out_channels >= 2) {+144
for (size_t f = 0; f < drain_frames; f++) {+145
float l = s->ring[(tail + 0) & s->mask];+146
float r = s->ring[(tail + 1) & s->mask];+147
out[f * out_channels + 0] += l * vol;+148
out[f * out_channels + 1] += r * vol;+149
tail += 2;+150
}+151
} else if (s->channels == 1 && out_channels >= 2) {+152
for (size_t f = 0; f < drain_frames; f++) {+153
float v = s->ring[tail & s->mask] * vol;+154
out[f * out_channels + 0] += v;+155
out[f * out_channels + 1] += v;+156
tail += 1;+157
}+158
} else if (s->channels == 1 && out_channels == 1) {+159
for (size_t f = 0; f < drain_frames; f++) {+160
out[f] += s->ring[tail & s->mask] * vol;+161
tail += 1;+162
}+163
} else { /* channels == 2, out == 1: downmix */+164
for (size_t f = 0; f < drain_frames; f++) {+165
float l = s->ring[(tail + 0) & s->mask];+166
float r = s->ring[(tail + 1) & s->mask];+167
out[f] += (l + r) * 0.5f * vol;+168
tail += 2;+169
}+170
}+171
+172
ATM_STORE_REL(&s->tail, tail);+173
+174
if (drain_frames < (size_t)num_frames) {+175
uint64_t missing = (uint64_t)(num_frames - (int)drain_frames);+176
ATM_ADD_RLX(&s->underrun_frames, missing);+177
}+178
}+179
+180
void sigil_audio_stream_mix_all(float *out, int num_frames, int out_channels)+181
{+182
for (int i = 0; i < MAX_AUDIO_STREAMS; i++) {+183
AudioStream *s = ATM_LOAD_ACQ(&g_streams[i]);+184
if (!s) continue;+185
if (ATM_LOAD_ACQ(&s->closed)) continue;+186
mix_one(s, out, num_frames, out_channels);+187
}+188
}+189
+190
void sigil_audio_stream_shutdown_all(void)+191
{+192
for (int i = 0; i < MAX_AUDIO_STREAMS; i++) {+193
AudioStream *s = ATM_LOAD_REL(&g_streams[i]);+194
if (s) {+195
ATM_STORE_REL(&s->closed, 1);+196
ATM_STORE_REL(&g_streams[i], (AudioStream *)NULL);+197
}+198
}+199
}+200
+201
/* ------------------------------------------------------------------ */+202
/* Natives */+203
/* ------------------------------------------------------------------ */+204
+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+209
* configured rate (44100) — caller must match.+210
*/+211
static Value native_open_audio_stream(SigilVM *vm, int argc, Value *args)+212
{+213
if (argc < 3) {+214
sigil__vm_set_error(vm, SIGIL_ERR_ARITY,+215
"%open-audio-stream: channels buffer-frames volume");+216
return SIGIL_FALSE;+217
}+218
int channels = (int)sigil_as_fixnum(args[0]);+219
int buffer_frames = (int)sigil_as_fixnum(args[1]);+220
float volume = (float)sigil_as_flonum(args[2]);+221
+222
if (channels != 1 && channels != 2) {+223
sigil__vm_set_error(vm, SIGIL_ERR_TYPE,+224
"open-audio-stream: channels must be 1 or 2");+225
return SIGIL_FALSE;+226
}+227
if (buffer_frames < MIN_BUFFER_FRAMES) buffer_frames = MIN_BUFFER_FRAMES;+228
if (buffer_frames > MAX_BUFFER_FRAMES) buffer_frames = MAX_BUFFER_FRAMES;+229
+230
size_t frames_pow2 = round_up_pow2((size_t)buffer_frames);+231
size_t cap_samples = frames_pow2 * (size_t)channels;+232
+233
AudioStream *s = calloc(1, sizeof(*s));+234
if (!s) return SIGIL_FALSE;+235
+236
s->ring = calloc(cap_samples, sizeof(float));+237
if (!s->ring) { free(s); return SIGIL_FALSE; }+238
+239
s->capacity_samples = cap_samples;+240
s->mask = cap_samples - 1;+241
s->channels = channels;+242
s->buffer_frames = (int)frames_pow2;+243
s->volume = volume;+244
s->head = 0;+245
s->tail = 0;+246
s->closed = 0;+247
s->underrun_frames = 0;+248
+249
if (registry_add(s) < 0) {+250
free(s->ring);+251
free(s);+252
sigil__vm_set_error(vm, SIGIL_ERR_RUNTIME,+253
"open-audio-stream: too many active streams");+254
return SIGIL_FALSE;+255
}+256
+257
ensure_stream_type(vm);+258
return sigil_make_foreign(vm, stream_type_tag, s, stream_destructor,+259
sizeof(AudioStream) + cap_samples * sizeof(float));+260
}+261
+262
/*+263
* (audio-stream? obj) -> boolean+264
*/+265
static Value native_stream_p(SigilVM *vm, int argc, Value *args)+266
{+267
(void)argc;+268
return get_stream(vm, args[0]) ? SIGIL_TRUE : SIGIL_FALSE;+269
}+270
+271
/*+272
* (push-audio-samples stream pcm-bv frames) -> fixnum+273
*+274
* Non-blocking. Returns frames actually copied (may be < frames).+275
* Returns 0 on a closed stream.+276
*/+277
static Value native_push_audio_samples(SigilVM *vm, int argc, Value *args)+278
{+279
if (argc < 3) {+280
sigil__vm_set_error(vm, SIGIL_ERR_ARITY,+281
"push-audio-samples: stream pcm-bv frames");+282
return sigil_fixnum(0);+283
}+284
AudioStream *s = get_stream(vm, args[0]);+285
if (!s) {+286
sigil__vm_set_error(vm, SIGIL_ERR_TYPE,+287
"push-audio-samples: expected audio-stream");+288
return sigil_fixnum(0);+289
}+290
if (!sigil_is_bytevector(args[1])) {+291
sigil__vm_set_error(vm, SIGIL_ERR_TYPE,+292
"push-audio-samples: expected bytevector");+293
return sigil_fixnum(0);+294
}+295
int64_t req_frames_i = sigil_as_fixnum(args[2]);+296
if (req_frames_i < 0) req_frames_i = 0;+297
+298
if (ATM_LOAD_ACQ(&s->closed)) {+299
return sigil_fixnum(0);+300
}+301
+302
size_t req_frames = (size_t)req_frames_i;+303
size_t req_samples = req_frames * (size_t)s->channels;+304
+305
/* Bytevector must hold at least req_samples * 4 bytes. */+306
size_t bv_bytes = sigil_bytevector_length(args[1]);+307
if (req_samples * sizeof(float) > bv_bytes) {+308
req_samples = bv_bytes / sizeof(float);+309
req_samples -= req_samples % (size_t)s->channels; /* frame-align */+310
req_frames = req_samples / (size_t)s->channels;+311
}+312
+313
uint64_t head = ATM_LOAD_REL(&s->head);+314
uint64_t tail = ATM_LOAD_ACQ(&s->tail);+315
size_t in_use = (size_t)(head - tail);+316
size_t room = s->capacity_samples - in_use;+317
+318
size_t write_samples = req_samples < room ? req_samples : room;+319
/* Whole-frame alignment. */+320
write_samples -= write_samples % (size_t)s->channels;+321
+322
const float *src = (const float *)sigil_bytevector_data(args[1]);+323
for (size_t i = 0; i < write_samples; i++) {+324
s->ring[(head + i) & s->mask] = src[i];+325
}+326
+327
ATM_STORE_REL(&s->head, head + write_samples);+328
+329
return sigil_fixnum((int64_t)(write_samples / (size_t)s->channels));+330
}+331
+332
/* (audio-stream-room stream) -> fixnum — frames free */+333
static Value native_stream_room(SigilVM *vm, int argc, Value *args)+334
{+335
(void)argc;+336
AudioStream *s = get_stream(vm, args[0]);+337
if (!s) return sigil_fixnum(0);+338
uint64_t head = ATM_LOAD_REL(&s->head);+339
uint64_t tail = ATM_LOAD_ACQ(&s->tail);+340
size_t in_use = (size_t)(head - tail);+341
size_t room_samples = s->capacity_samples - in_use;+342
return sigil_fixnum((int64_t)(room_samples / (size_t)s->channels));+343
}+344
+345
/* (audio-stream-depth stream) -> fixnum — frames queued */+346
static Value native_stream_depth(SigilVM *vm, int argc, Value *args)+347
{+348
(void)argc;+349
AudioStream *s = get_stream(vm, args[0]);+350
if (!s) return sigil_fixnum(0);+351
uint64_t head = ATM_LOAD_ACQ(&s->head);+352
uint64_t tail = ATM_LOAD_ACQ(&s->tail);+353
size_t in_use = (size_t)(head - tail);+354
return sigil_fixnum((int64_t)(in_use / (size_t)s->channels));+355
}+356
+357
/* (audio-stream-capacity stream) -> fixnum — total ring size in frames */+358
static Value native_stream_capacity(SigilVM *vm, int argc, Value *args)+359
{+360
(void)argc;+361
AudioStream *s = get_stream(vm, args[0]);+362
if (!s) return sigil_fixnum(0);+363
return sigil_fixnum((int64_t)s->buffer_frames);+364
}+365
+366
/* (audio-stream-channels stream) -> fixnum */+367
static Value native_stream_channels(SigilVM *vm, int argc, Value *args)+368
{+369
(void)argc;+370
AudioStream *s = get_stream(vm, args[0]);+371
if (!s) return sigil_fixnum(0);+372
return sigil_fixnum((int64_t)s->channels);+373
}+374
+375
/* (audio-stream-underruns stream) -> fixnum — cumulative underrun frames */+376
static Value native_stream_underruns(SigilVM *vm, int argc, Value *args)+377
{+378
(void)argc;+379
AudioStream *s = get_stream(vm, args[0]);+380
if (!s) return sigil_fixnum(0);+381
uint64_t u = ATM_LOAD_REL(&s->underrun_frames);+382
if (u > (uint64_t)SIGIL_FIXNUM_MAX) u = (uint64_t)SIGIL_FIXNUM_MAX;+383
return sigil_fixnum((int64_t)u);+384
}+385
+386
/* (audio-stream-closed? stream) -> boolean */+387
static Value native_stream_closed_p(SigilVM *vm, int argc, Value *args)+388
{+389
(void)argc;+390
AudioStream *s = get_stream(vm, args[0]);+391
if (!s) return SIGIL_TRUE;+392
return ATM_LOAD_ACQ(&s->closed) ? SIGIL_TRUE : SIGIL_FALSE;+393
}+394
+395
/* (close-audio-stream! stream) — unregister; destructor frees on GC */+396
static Value native_close_audio_stream(SigilVM *vm, int argc, Value *args)+397
{+398
(void)argc;+399
AudioStream *s = get_stream(vm, args[0]);+400
if (!s) return SIGIL_NIL;+401
if (ATM_XCHG_ACQREL(&s->closed, 1)) {+402
return SIGIL_NIL;+403
}+404
registry_remove(s);+405
return SIGIL_NIL;+406
}+407
+408
/*+409
* (%audio-stream-drain-for-test out-bv num-frames out-channels) -> fixnum+410
*+411
* Test-only shim: simulates the audio thread by running the mix pass on+412
* a Sigil-provided float32 bytevector. Returns num-frames (nothing fancy+413
* — the caller checks sample values). Treats the bytevector as+414
* zero-initialized each call; the caller is responsible for clearing.+415
* Not exported from the Sigil library — %-prefix signals internal.+416
*/+417
static Value native_drain_for_test(SigilVM *vm, int argc, Value *args)+418
{+419
if (argc < 3 || !sigil_is_bytevector(args[0])) {+420
sigil__vm_set_error(vm, SIGIL_ERR_TYPE,+421
"%audio-stream-drain-for-test: out-bv frames channels");+422
return sigil_fixnum(0);+423
}+424
float *out = (float *)sigil_bytevector_data(args[0]);+425
size_t bv_bytes = sigil_bytevector_length(args[0]);+426
int num_frames = (int)sigil_as_fixnum(args[1]);+427
int out_channels = (int)sigil_as_fixnum(args[2]);+428
+429
size_t need_bytes = (size_t)num_frames * (size_t)out_channels * sizeof(float);+430
if (need_bytes > bv_bytes) {+431
sigil__vm_set_error(vm, SIGIL_ERR_RUNTIME,+432
"%audio-stream-drain-for-test: bytevector too small");+433
return sigil_fixnum(0);+434
}+435
memset(out, 0, need_bytes);+436
sigil_audio_stream_mix_all(out, num_frames, out_channels);+437
return sigil_fixnum((int64_t)num_frames);+438
}+439
+440
/*+441
* (%bv-f32-ref bv byte-offset) -> flonum+442
*+443
* Test-only: read a float32 from a bytevector at byte-offset, widened to+444
* a flonum. Sigil stdlib doesn't ship a float32 bytevector accessor yet.+445
*/+446
static Value native_bv_f32_ref(SigilVM *vm, int argc, Value *args)+447
{+448
if (argc < 2 || !sigil_is_bytevector(args[0])) {+449
sigil__vm_set_error(vm, SIGIL_ERR_TYPE,+450
"%bv-f32-ref: bv offset");+451
return sigil_flonum(0.0);+452
}+453
int64_t off = sigil_as_fixnum(args[1]);+454
size_t bv_bytes = sigil_bytevector_length(args[0]);+455
if (off < 0 || (size_t)off + sizeof(float) > bv_bytes) {+456
sigil__vm_set_error(vm, SIGIL_ERR_RUNTIME,+457
"%bv-f32-ref: offset out of bounds");+458
return sigil_flonum(0.0);+459
}+460
float f;+461
memcpy(&f, sigil_bytevector_data(args[0]) + off, sizeof(f));+462
return sigil_flonum((double)f);+463
}+464
+465
/* (set-audio-stream-volume! stream volume) */+466
static Value native_set_stream_volume(SigilVM *vm, int argc, Value *args)+467
{+468
if (argc < 2) {+469
sigil__vm_set_error(vm, SIGIL_ERR_ARITY,+470
"set-audio-stream-volume!: stream volume");+471
return SIGIL_NIL;+472
}+473
AudioStream *s = get_stream(vm, args[0]);+474
if (!s) {+475
sigil__vm_set_error(vm, SIGIL_ERR_TYPE,+476
"set-audio-stream-volume!: expected audio-stream");+477
return SIGIL_NIL;+478
}+479
float v = (float)sigil_as_flonum(args[1]);+480
if (v < 0.0f) v = 0.0f;+481
if (v > 1.0f) v = 1.0f;+482
s->volume = v; /* audio thread reads non-atomically; single-writer producer */+483
return SIGIL_NIL;+484
}+485
+486
/* ------------------------------------------------------------------ */+487
/* Module registration */+488
/* ------------------------------------------------------------------ */+489
+490
void sigil__register_audio_stream(SigilVM *vm)+491
{+492
sigil_module_register_native(vm, "%open-audio-stream",+493
native_open_audio_stream, SIGIL_ARITY_EXACT(3),+494
"Open a streaming audio sink (internal)");+495
sigil_module_register_native(vm, "audio-stream?",+496
native_stream_p, SIGIL_ARITY_EXACT(1),+497
"Check if object is an audio-stream");+498
sigil_module_register_native(vm, "push-audio-samples",+499
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
@@ -0,0 +1,26 @@
+1
/*+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+6
* folio topics/sigil-audio-streaming-sink-architecture.+7
*/+8
+9
#ifndef SIGIL_AUDIO_STREAM_H+10
#define SIGIL_AUDIO_STREAM_H+11
+12
#include "sigil/sigil.h"+13
+14
/* Called from the audio callback (audio.c) after play-sound / play-music+15
* mixing. Drains each registered stream into the output buffer, padding+16
* with silence on under-run. Stream mix is additive. */+17
void sigil_audio_stream_mix_all(float *out, int num_frames, int out_channels);+18
+19
/* Register natives into the current module; called from audio.c init. */+20
void sigil__register_audio_stream(SigilVM *vm);+21
+22
/* Called from audio-shutdown to stop and detach all streams. Frees no+23
* memory — stream handles stay alive via GC, but are marked closed. */+24
void sigil_audio_stream_shutdown_all(void);+25
+26
#endifsrc/c/audio.cmodified
@@ -21,6 +21,9 @@
21
/* stb_vorbis for OGG decoding (implementation in stb_impl.c) */ 22
#include "stb_vorbis.c" 23
+24
/* Streaming audio sink (SPSC ring) */+25
#include "audio-stream.h"+26
27
/* ============================================================ 28
* CONSTANTS 29
* ============================================================ */@@ -180,6 +183,9 @@ static void audio_callback(float *buffer, int num_frames, int num_channels)
183
} 184
} 185
+186
/* Mix streaming sinks (SPSC ring sources). Additive, silence on under-run. */+187
sigil_audio_stream_mix_all(buffer, num_frames, num_channels);+188
189
/* Clamp output */ 190
for (int i = 0; i < num_frames * num_channels; i++) { 191
if (buffer[i] > 1.0f) buffer[i] = 1.0f;@@ -270,6 +276,9 @@ static Value native_audio_shutdown(SigilVM *vm, int argc, Value *args)
276
(void)vm; (void)argc; (void)args; 277
278
if (g_audio_initialized) {+279
/* Detach any active streaming sinks before tearing down the device */+280
sigil_audio_stream_shutdown_all();+281
282
/* Stop music */ 283
if (g_music.vorbis) { 284
stb_vorbis_close(g_music.vorbis);@@ -639,5 +648,8 @@ void sigil__init_sigil_audio_module(SigilVM *vm)
648
/* OGG encoding */ 649
sigil__register_ogg_encode(vm); 650
+651
/* Streaming sink */+652
sigil__register_audio_stream(vm);+653
654
sigil_end_module(vm); 655
}src/sigil/audio.sglmodified
@@ -41,6 +41,8 @@
41
(export 42
;; OGG encoding (Scheme wrapper with keyword args) 43
write-ogg+44
;; Streaming sink (Scheme wrapper with keyword args; rest native)+45
open-audio-stream 46
;; Playlist utilities (Scheme-defined) 47
wait-music-end 48
play-playlist)@@ -75,6 +77,36 @@
77
(quality 0.4))) 78
(%write-ogg path sample-buffer sample-rate channels quality)) 79
+80
;; ============================================================+81
;; Streaming Audio Sink+82
;; ============================================================+83
+84
;;; Open a streaming audio sink.+85
;;;+86
;;; Returns an `<audio-stream>` handle. Push interleaved float32 PCM+87
;;; with `push-audio-samples`; the audio thread drains the ring and+88
;;; mixes the result alongside `play-sound` / `play-music`.+89
;;;+90
;;; Keywords:+91
;;; channels: 1 (mono, duplicated to stereo out) or 2 (stereo).+92
;;; Default 2.+93
;;; buffer-frames: Ring capacity in frames. Rounded up to a power+94
;;; of two. Default 16384 (~370ms at 44.1 kHz) —+95
;;; this is safety margin, not latency. Shrink it+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+100
;;; rate (44100 today). No resampling is performed.+101
;;;+102
;;; Audio must be set up (`audio-setup`) first. Up to 4 streams may+103
;;; be open simultaneously.+104
(define (open-audio-stream+105
(keys: (channels 2)+106
(buffer-frames 16384)+107
(volume 1.0)))+108
(%open-audio-stream channels buffer-frames volume))+109
110
;; ============================================================ 111
;; Playlist Utilities 112
;; ============================================================test/main.cmodified
@@ -28,6 +28,10 @@ int main(int argc, char *argv[])
28
} 29
30
/* Add load paths */+31
const char *stdlib_override = getenv("SIGIL_STDLIB_LIB");+32
if (stdlib_override) {+33
sigil_vm_add_load_path(vm, stdlib_override);+34
} 35
sigil_vm_add_load_path(vm, "deps/sigil-stdlib/src"); 36
sigil_vm_add_load_path(vm, "build/release/lib"); 37
sigil_vm_add_load_path(vm, "build/dev/lib");test/test-streaming-sink.sgladded
@@ -0,0 +1,144 @@
+1
;;; Test streaming audio sink+2
;;;+3
;;; Exercises the SPSC ring buffer without opening a real sokol_audio+4
;;; device. %audio-stream-drain-for-test stands in for the audio+5
;;; callback. Verifies push/consume parity, under-run, and over-run.+6
+7
(import (sigil core)+8
(sigil audio))+9
+10
(define failures 0)+11
+12
(define (check label actual expected)+13
(if (equal? actual expected)+14
(begin (display " ok: ") (display label) (newline))+15
(begin+16
(display " FAIL: ") (display label) (newline)+17
(display " expected: ") (display expected) (newline)+18
(display " actual: ") (display actual) (newline)+19
(set! failures (+ failures 1)))))+20
+21
(define (check-pred label actual pred)+22
(if (pred actual)+23
(begin (display " ok: ") (display label) (newline))+24
(begin+25
(display " FAIL: ") (display label) (newline)+26
(display " actual: ") (display actual) (newline)+27
(set! failures (+ failures 1)))))+28
+29
;; Build a stereo float32 bytevector of N frames where every sample == v.+30
(define (const-frames n-frames channels v)+31
(let* ((n-samples (* n-frames channels))+32
(bv (make-float-buffer (make-vector n-samples v))))+33
bv))+34
+35
;; ------------------------------------------------------------+36
;; Test 1: basic push / drain / depth accounting+37
;; ------------------------------------------------------------+38
(display "Test 1: basic push/drain (stereo, 2048-frame ring)\n")+39
(let ((s (open-audio-stream channels: 2 buffer-frames: 2048)))+40
(check "audio-stream? true" (audio-stream? s) #t)+41
(check "depth initially zero" (audio-stream-depth s) 0)+42
;; capacity rounds up to next power of two+43
(check-pred "capacity >= 2048" (audio-stream-capacity s) (lambda (c) (>= c 2048)))+44
(check "channels == 2" (audio-stream-channels s) 2)+45
+46
(let* ((n 512)+47
(bv (const-frames n 2 0.25))+48
(accepted (push-audio-samples s bv n)))+49
(check "all 512 frames accepted" accepted n)+50
(check "depth == 512" (audio-stream-depth s) 512)+51
+52
;; Drain 256 frames via the test shim.+53
(let ((out (make-bytevector (* 256 2 4) 0)))+54
(%audio-stream-drain-for-test out 256 2)+55
(check "depth after drain 256" (audio-stream-depth s) 256)+56
;; First float in out should be ~0.25 (our stream is the only+57
;; source).+58
(check-pred "first sample == 0.25"+59
(%bv-f32-ref out 0)+60
(lambda (v) (< (abs (- v 0.25)) 1e-6))))+61
+62
;; Drain the remaining 256 plus 128 of silence — under-run.+63
(let ((out (make-bytevector (* 384 2 4) 0)))+64
(%audio-stream-drain-for-test out 384 2)+65
(check "depth zero after overflow drain" (audio-stream-depth s) 0)+66
(check-pred "underruns >= 128" (audio-stream-underruns s)+67
(lambda (u) (>= u 128)))+68
;; First sample still from the stream (0.25), tail sample silence.+69
(check-pred "first sample still 0.25"+70
(%bv-f32-ref out 0)+71
(lambda (v) (< (abs (- v 0.25)) 1e-6)))+72
(check-pred "last sample is silence"+73
(%bv-f32-ref out (* (- 384 1) 2 4))+74
(lambda (v) (< (abs v) 1e-6)))))+75
(close-audio-stream! s)+76
(check "audio-stream-closed? after close" (audio-stream-closed? s) #t)+77
(check "push on closed returns 0"+78
(push-audio-samples s (const-frames 64 2 0.1) 64) 0))+79
+80
;; ------------------------------------------------------------+81
;; Test 2: over-run — push more than capacity+82
;; ------------------------------------------------------------+83
(display "\nTest 2: over-run (push exceeds ring capacity)\n")+84
(let* ((s (open-audio-stream channels: 2 buffer-frames: 1024))+85
(cap (audio-stream-capacity s))+86
(big (* cap 2)) ;; ask to push 2x capacity+87
(bv (const-frames big 2 0.5))+88
(accepted (push-audio-samples s bv big)))+89
(check-pred "accepted <= capacity" accepted (lambda (a) (<= a cap)))+90
(check-pred "accepted > 0" accepted (lambda (a) (> a 0)))+91
(check "depth == accepted" (audio-stream-depth s) accepted)+92
(check "room == 0 (full)" (audio-stream-room s) 0)+93
;; A second push while full should accept 0.+94
(check "second push returns 0"+95
(push-audio-samples s (const-frames 128 2 0.9) 128) 0)+96
(close-audio-stream! s))+97
+98
;; ------------------------------------------------------------+99
;; Test 3: mono stream duplicates to stereo out+100
;; ------------------------------------------------------------+101
(display "\nTest 3: mono stream duplicated into stereo out\n")+102
(let ((s (open-audio-stream channels: 1 buffer-frames: 512)))+103
(check "channels == 1" (audio-stream-channels s) 1)+104
(let* ((n 128)+105
(bv (const-frames n 1 0.5))+106
(accepted (push-audio-samples s bv n)))+107
(check "mono push accepted 128" accepted n))+108
(let ((out (make-bytevector (* 128 2 4) 0)))+109
(%audio-stream-drain-for-test out 128 2)+110
(check-pred "L channel == 0.5"+111
(%bv-f32-ref out 0)+112
(lambda (v) (< (abs (- v 0.5)) 1e-6)))+113
(check-pred "R channel == 0.5"+114
(%bv-f32-ref out 4)+115
(lambda (v) (< (abs (- v 0.5)) 1e-6))))+116
(close-audio-stream! s))+117
+118
;; ------------------------------------------------------------+119
;; Test 4: close is idempotent and depth/room safe on closed+120
;; ------------------------------------------------------------+121
(display "\nTest 4: close idempotent\n")+122
(let ((s (open-audio-stream channels: 2 buffer-frames: 256)))+123
(close-audio-stream! s)+124
(close-audio-stream! s) ;; no crash+125
(check "closed? after double close" (audio-stream-closed? s) #t))+126
+127
;; ------------------------------------------------------------+128
;; Test 5: volume set reflected in drained samples+129
;; ------------------------------------------------------------+130
(display "\nTest 5: set-audio-stream-volume!\n")+131
(let ((s (open-audio-stream channels: 2 buffer-frames: 512 volume: 1.0)))+132
(set-audio-stream-volume! s 0.5)+133
(push-audio-samples s (const-frames 64 2 1.0) 64)+134
(let ((out (make-bytevector (* 64 2 4) 0)))+135
(%audio-stream-drain-for-test out 64 2)+136
(check-pred "sample scaled by 0.5"+137
(%bv-f32-ref out 0)+138
(lambda (v) (< (abs (- v 0.5)) 1e-6))))+139
(close-audio-stream! s))+140
+141
(newline)+142
(if (= failures 0)+143
(display "All streaming-sink tests passed!\n")+144
(error "streaming-sink test failures" failures))