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
### Added
README.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-Clause
package.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
#endif
src/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))