Commitdecac75fRecorded24 Sep 2026Repositorysigil-audio
Native parity for the sink: resampling streams, the underrun split, and cues
Message
A stream can be fed below the device's rate (rate: on open-audio-stream, set-audio-stream-rate! later); the audio thread upsamples with a 4-tap cubic, and a change lands at the write head it was asked at. Starved frames are split into sounding and idle by the level of what played just before the run. The facade gains load-audio-cue and play-audio-cue, which play a sound from memory through the device callback, so a cue does not wait behind a stream's ring; the web arm gets the same calls from sigil-wasm-audio.
play-sound's playing flag is now published with release/acquire ordering, so the audio thread never sees a slot marked playing before its fields.
Changed
src/c/audio-stream.c | 229 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
src/c/audio-stream.h | 4 +++
src/c/audio.c | 69 ++++++++++++++++++++++++++++++++++++++++++++++---
src/sigil/audio.sgl | 30 ++++++++++++++++++----
src/sigil/audio/sink.sgl | 119 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
test/test-sink-rate-split.sgl | 172 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 574 insertions(+), 49 deletions(-)Diff
src/c/audio-stream.cmodified
@@ -42,6 +42,21 @@ static inline int atm_cas_release(void *ptr, void *expected, void *desired)
42
#define MIN_BUFFER_FRAMES 64 43
#define MAX_BUFFER_FRAMES (1 << 20) /* ~24 s at 44.1 kHz stereo */ 44
+45
/* Rate changes a stream may have pending at once (a power of two). */+46
#define RATE_QUEUE 8+47
+48
/* A sample above this (after the stream's volume) is sound: -80 dB. */+49
#define SILENCE 1e-4f+50
/* Frames a loud sample keeps the stream "sounding": a dry run that starts+51
* within them cut sound off, an audible gap; one that starts later is a+52
* quiet stream running dry, which nobody hears. */+53
#define LOUD_HOLD 128+54
+55
typedef struct RateChange {+56
uint64_t at; /* the producer's head (samples) when asked */+57
double ratio; /* stream rate / device rate, in (0, 1] */+58
} RateChange;+59
60
typedef struct AudioStream { 61
float *ring; /* capacity_samples floats */ 62
size_t capacity_samples;/* power of two, = frames_pow2 * channels */@@ -53,9 +68,29 @@ typedef struct AudioStream {
68
uint64_t tail; /* consumer read index (in samples) */ 69
int closed; 70
uint64_t underrun_frames;+71
uint64_t underrun_sounding; /* of those, the frames that cut off sound */ 72
73
float volume; /* single-writer from the producer side; 74
* audio thread reads non-atomically. */+75
+76
/* The rate the producer feeds at (producer side, for audio-stream-rate),+77
* and the pending changes: each lands when the consumer's tail reaches+78
* the head it was asked at. SPSC: the producer owns q_head, the audio+79
* thread q_tail. */+80
int rate;+81
RateChange q[RATE_QUEUE];+82
uint32_t q_head;+83
uint32_t q_tail;+84
+85
/* Drain state, audio thread only: the upsampler's ratio (the stream's+86
* rate / the device's), fractional position and the frame before the+87
* read position; the loudness hold and the current dry run's class. */+88
double ratio;+89
double frac;+90
float y0l, y0r;+91
int loud;+92
int dry;+93
int run_sounding; 94
} AudioStream; 95
96
/* Registry of active streams. Entries may be NULL. */@@ -126,55 +161,112 @@ static void stream_destructor(void *data)
161
/* Audio-thread mix */ 162
/* ------------------------------------------------------------------ */ 163
+164
/* One stream into the device buffer, additively. At the device's rate the+165
* ring is copied sample for sample; below it, upsampled with a 4-tap cubic+166
* Hermite over the frame before the read position and the three from it on+167
* (the same drain as the web sink's). Starved frames are counted, and split+168
* into sounding and idle by the level of what played just before the run. */ 169
static void mix_one(AudioStream *s, float *out, int num_frames, 170
int out_channels) 171
{ 172
uint64_t head = ATM_LOAD_ACQ(&s->head); 173
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
+174
const int ch = s->channels;+175
const float *ring = s->ring;+176
const size_t mask = s->mask;+177
uint64_t avail = (head - tail) / (uint64_t)ch; /* frames */ 178
float vol = s->volume; 179
−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;+180
double ratio = s->ratio, frac = s->frac;+181
float y0l = s->y0l, y0r = s->y0r;+182
int loud = s->loud, dry = s->dry, run_sounding = s->run_sounding;+183
uint64_t under = 0, under_sounding = 0;+184
+185
for (int f = 0; f < num_frames; f++) {+186
/* a rate change lands when the read position reaches its head */+187
uint32_t qt = s->q_tail;+188
if (qt != ATM_LOAD_ACQ(&s->q_head)) {+189
const RateChange *c = &s->q[qt & (RATE_QUEUE - 1)];+190
if (tail >= c->at) {+191
ratio = c->ratio;+192
if (ratio == 1.0) frac = 0.0;+193
ATM_STORE_REL(&s->q_tail, qt + 1);+194
} 195
}−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;+196
float l = 0.0f, r = 0.0f;+197
int played = 0;+198
if (ratio == 1.0) {+199
if (avail > 0) {+200
y0l = ring[tail & mask];+201
y0r = ch == 2 ? ring[(tail + 1) & mask] : y0l;+202
l = y0l;+203
r = y0r;+204
tail += (uint64_t)ch;+205
avail--;+206
played = 1;+207
}+208
} else if (avail >= 3) {+209
const float *p1 = &ring[tail & mask];+210
const float *p2 = &ring[(tail + (uint64_t)ch) & mask];+211
const float *p3 = &ring[(tail + 2 * (uint64_t)ch) & mask];+212
double t = frac;+213
double a = -0.5 * y0l + 1.5 * p1[0] - 1.5 * p2[0] + 0.5 * p3[0];+214
double b = y0l - 2.5 * p1[0] + 2.0 * p2[0] - 0.5 * p3[0];+215
double c = -0.5 * y0l + 0.5 * p2[0];+216
l = (float)(((a * t + b) * t + c) * t + p1[0]);+217
if (ch == 2) {+218
const float q1 = ring[(tail + 1) & mask];+219
const float q2 = ring[(tail + (uint64_t)ch + 1) & mask];+220
const float q3 = ring[(tail + 2 * (uint64_t)ch + 1) & mask];+221
a = -0.5 * y0r + 1.5 * q1 - 1.5 * q2 + 0.5 * q3;+222
b = y0r - 2.5 * q1 + 2.0 * q2 - 0.5 * q3;+223
c = -0.5 * y0r + 0.5 * q2;+224
r = (float)(((a * t + b) * t + c) * t + q1);+225
} else {+226
r = l;+227
}+228
frac += ratio;+229
while (frac >= 1.0) {+230
y0l = ring[tail & mask];+231
y0r = ch == 2 ? ring[(tail + 1) & mask] : y0l;+232
tail += (uint64_t)ch;+233
avail--;+234
frac -= 1.0;+235
}+236
played = 1; 237
}−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;+238
if (played) {+239
l *= vol;+240
r *= vol;+241
dry = 0;+242
if (l > SILENCE || l < -SILENCE || r > SILENCE || r < -SILENCE) loud = LOUD_HOLD;+243
else if (loud > 0) loud--;+244
if (out_channels >= 2) {+245
out[f * out_channels + 0] += l;+246
out[f * out_channels + 1] += r;+247
} else {+248
out[f] += ch == 2 ? (l + r) * 0.5f : l;+249
}+250
} else {+251
if (!dry) {+252
dry = 1;+253
run_sounding = loud > 0;+254
}+255
under++;+256
if (run_sounding) under_sounding++; 257
} 258
} 259
260
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
}+261
s->ratio = ratio;+262
s->frac = frac;+263
s->y0l = y0l;+264
s->y0r = y0r;+265
s->loud = loud;+266
s->dry = dry;+267
s->run_sounding = run_sounding;+268
if (under) ATM_ADD_RLX(&s->underrun_frames, under);+269
if (under_sounding) ATM_ADD_RLX(&s->underrun_sounding, under_sounding); 270
} 271
272
void sigil_audio_stream_mix_all(float *out, int num_frames, int out_channels)@@ -245,6 +337,16 @@ static Value native_open_audio_stream(SigilVM *vm, int argc, Value *args)
337
s->tail = 0; 338
s->closed = 0; 339
s->underrun_frames = 0;+340
s->underrun_sounding = 0;+341
s->rate = SIGIL_AUDIO_DEVICE_RATE;+342
s->q_head = 0;+343
s->q_tail = 0;+344
s->ratio = 1.0;+345
s->frac = 0.0;+346
s->y0l = s->y0r = 0.0f;+347
s->loud = 0;+348
s->dry = 0;+349
s->run_sounding = 0; 350
351
if (registry_add(s) < 0) { 352
free(s->ring);@@ -383,6 +485,49 @@ static Value native_stream_underruns(SigilVM *vm, int argc, Value *args)
485
return sigil_fixnum((int64_t)u); 486
} 487
+488
/* (audio-stream-underruns-sounding stream) -> fixnum: the underrun frames+489
* that cut off sound (the rest is a quiet stream running dry) */+490
static Value native_stream_underruns_sounding(SigilVM *vm, int argc, Value *args)+491
{+492
(void)argc;+493
AudioStream *s = get_stream(vm, args[0]);+494
if (!s) return sigil_fixnum(0);+495
uint64_t u = ATM_LOAD_REL(&s->underrun_sounding);+496
if (u > (uint64_t)SIGIL_FIXNUM_MAX) u = (uint64_t)SIGIL_FIXNUM_MAX;+497
return sigil_fixnum((int64_t)u);+498
}+499
+500
/* (audio-stream-rate stream) -> fixnum: the rate the producer feeds at */+501
static Value native_stream_rate(SigilVM *vm, int argc, Value *args)+502
{+503
(void)argc;+504
AudioStream *s = get_stream(vm, args[0]);+505
if (!s) return sigil_fixnum(0);+506
return sigil_fixnum((int64_t)s->rate);+507
}+508
+509
/* (%audio-stream-set-rate! stream rate) -> #t | #f+510
* The producer feeds at RATE from its head on (clamped to the device's rate);+511
* the audio thread switches when it reaches that head. #f when RATE_QUEUE+512
* changes are already pending (the audio thread has not reached them). */+513
static Value native_stream_set_rate(SigilVM *vm, int argc, Value *args)+514
{+515
(void)argc;+516
AudioStream *s = get_stream(vm, args[0]);+517
if (!s || !sigil_is_fixnum(args[1])) return SIGIL_FALSE;+518
int64_t rate = sigil_as_fixnum(args[1]);+519
if (rate <= 0) return SIGIL_FALSE;+520
if (rate > SIGIL_AUDIO_DEVICE_RATE) rate = SIGIL_AUDIO_DEVICE_RATE;+521
uint32_t qh = s->q_head;+522
if (qh - ATM_LOAD_ACQ(&s->q_tail) >= RATE_QUEUE) return SIGIL_FALSE;+523
RateChange *c = &s->q[qh & (RATE_QUEUE - 1)];+524
c->at = ATM_LOAD_REL(&s->head);+525
c->ratio = rate == SIGIL_AUDIO_DEVICE_RATE ? 1.0 : (double)rate / (double)SIGIL_AUDIO_DEVICE_RATE;+526
ATM_STORE_REL(&s->q_head, qh + 1);+527
s->rate = (int)rate;+528
return SIGIL_TRUE;+529
}+530
531
/* (audio-stream-closed? stream) -> boolean */ 532
static Value native_stream_closed_p(SigilVM *vm, int argc, Value *args) 533
{@@ -513,6 +658,15 @@ void sigil__register_audio_stream(SigilVM *vm)
658
sigil_module_register_native(vm, "audio-stream-underruns", 659
native_stream_underruns, SIGIL_ARITY_EXACT(1), 660
"Cumulative underrun frames (debug)");+661
sigil_module_register_native(vm, "audio-stream-underruns-sounding",+662
native_stream_underruns_sounding, SIGIL_ARITY_EXACT(1),+663
"Underrun frames that cut off sound");+664
sigil_module_register_native(vm, "audio-stream-rate",+665
native_stream_rate, SIGIL_ARITY_EXACT(1),+666
"The rate the producer feeds the stream at");+667
sigil_module_register_native(vm, "%audio-stream-set-rate!",+668
native_stream_set_rate, SIGIL_ARITY_EXACT(2),+669
"Set the stream's rate from its head on (internal)"); 670
sigil_module_register_native(vm, "audio-stream-closed?", 671
native_stream_closed_p, SIGIL_ARITY_EXACT(1), 672
"Is the stream closed?");@@ -537,6 +691,9 @@ void sigil__register_audio_stream(SigilVM *vm)
691
sigil_module_export(vm, "audio-stream-capacity"); 692
sigil_module_export(vm, "audio-stream-channels"); 693
sigil_module_export(vm, "audio-stream-underruns");+694
sigil_module_export(vm, "audio-stream-underruns-sounding");+695
sigil_module_export(vm, "audio-stream-rate");+696
sigil_module_export(vm, "%audio-stream-set-rate!"); 697
sigil_module_export(vm, "audio-stream-closed?"); 698
sigil_module_export(vm, "close-audio-stream!"); 699
sigil_module_export(vm, "set-audio-stream-volume!");src/c/audio-stream.hmodified
@@ -11,6 +11,10 @@
11
12
#include "sigil/sigil.h" 13
+14
/* The playback device's rate: audio.c opens the device at it, and a stream+15
* fed below it (audio-stream set-rate) is upsampled to it. */+16
#define SIGIL_AUDIO_DEVICE_RATE 44100+17
18
/* Called from the audio callback (audio.c) after play-sound / play-music 19
* mixing. Drains each registered stream into the output buffer, padding 20
* with silence on under-run. Stream mix is additive. */src/c/audio.cmodified
@@ -72,6 +72,7 @@ typedef struct {
72
static PlayingSound g_playing_sounds[MAX_PLAYING_SOUNDS]; 73
static MusicStream g_music = {0}; 74
static float g_master_volume = 1.0f;+75
static float g_sounds_volume = 1.0f; /* the sounds' own gain (cues), under the master */ 76
static bool g_muted = false; 77
static bool g_audio_initialized = false; 78
@@ -94,10 +95,11 @@ static void audio_callback(float *buffer, int num_frames, int num_channels)
95
/* Mix playing sounds */ 96
for (int i = 0; i < MAX_PLAYING_SOUNDS; i++) { 97
PlayingSound *ps = &g_playing_sounds[i];−97
if (!ps->playing || !ps->sound) continue;+98
/* acquire: the slot's fields were written before playing was set */+99
if (!__atomic_load_n(&ps->playing, __ATOMIC_ACQUIRE) || !ps->sound) continue; 100
101
StudioSound *snd = ps->sound;−100
float vol = ps->volume * master;+102
float vol = ps->volume * master * g_sounds_volume; 103
104
/* Calculate pan gains */ 105
float pan = ps->pan;@@ -251,7 +253,7 @@ static Value native_audio_setup(SigilVM *vm, int argc, Value *args)
253
* an error, as it was not under sokol_audio: the mixer runs with no 254
* output, every play is a no-op, and (audio-initialized?) is still 255
* #t. miniaudio-impl.c has already said why on stderr. */−254
if (sigil_audio_device_open(44100, 2, 2048, audio_callback) != 0) {+256
if (sigil_audio_device_open(SIGIL_AUDIO_DEVICE_RATE, 2, 2048, audio_callback) != 0) { 257
fprintf(stderr, "sigil-audio: continuing without an audio device\n"); 258
} 259
@@ -428,11 +430,64 @@ static Value native_play_sound(SigilVM *vm, int argc, Value *args)
430
ps->volume = argc > 1 ? (float)sigil_as_flonum(args[1]) : 1.0f; 431
ps->pan = argc > 2 ? (float)sigil_as_flonum(args[2]) : 0.0f; 432
ps->loop = argc > 3 ? sigil_is_true(args[3]) : false;−431
ps->playing = true;+433
/* release: the audio thread sees the fields above once it sees playing */+434
__atomic_store_n(&ps->playing, true, __ATOMIC_RELEASE); 435
436
return SIGIL_TRUE; 437
} 438
+439
/*+440
* (%sound-from-buffer bv channels) -> sound | #f+441
*+442
* A sound from interleaved float32 PCM in memory (channels 1 or 2) at the+443
* device's rate, copied: the cues of (sigil audio sink) on native. play-sound+444
* mixes it in the device callback, so it is heard one device period after+445
* the call, whatever any stream's ring holds.+446
*/+447
static Value native_sound_from_buffer(SigilVM *vm, int argc, Value *args)+448
{+449
if (argc < 2 || !sigil_is_bytevector(args[0]) || !sigil_is_fixnum(args[1])) {+450
sigil__vm_set_error(vm, SIGIL_ERR_TYPE, "%sound-from-buffer: bytevector channels");+451
return SIGIL_FALSE;+452
}+453
int channels = sigil_as_fixnum(args[1]) == 1 ? 1 : 2;+454
size_t bytes = sigil_bytevector_length(args[0]);+455
int frames = (int)(bytes / (sizeof(float) * (size_t)channels));+456
if (frames <= 0) return SIGIL_FALSE;+457
int total = frames * channels;+458
float *samples = malloc((size_t)total * sizeof(float));+459
if (!samples) return SIGIL_FALSE;+460
memcpy(samples, sigil_bytevector_data(args[0]), (size_t)total * sizeof(float));+461
StudioSound *snd = malloc(sizeof(StudioSound));+462
if (!snd) {+463
free(samples);+464
return SIGIL_FALSE;+465
}+466
snd->samples = samples;+467
snd->num_samples = total;+468
snd->sample_rate = SIGIL_AUDIO_DEVICE_RATE;+469
snd->channels = channels;+470
ensure_sound_type(vm);+471
return sigil_make_foreign(vm, sound_type_tag, snd, sound_destructor,+472
sizeof(StudioSound) + (size_t)total * sizeof(float));+473
}+474
+475
/*+476
* (%set-sounds-volume volume) - the gain of every play-sound sound, under+477
* the master volume (the cues' volume on native)+478
*/+479
static Value native_set_sounds_volume(SigilVM *vm, int argc, Value *args)+480
{+481
(void)vm; (void)argc;+482
float v = 1.0f;+483
if (sigil_is_flonum(args[0])) v = (float)sigil_as_flonum(args[0]);+484
else if (sigil_is_fixnum(args[0])) v = (float)sigil_as_fixnum(args[0]);+485
if (v < 0.0f) v = 0.0f;+486
if (v > 1.0f) v = 1.0f;+487
g_sounds_volume = v;+488
return SIGIL_NIL;+489
}+490
491
/* 492
* (stop-all-sounds) - Stop all playing sound effects 493
*/@@ -637,6 +692,10 @@ void sigil__init_sigil_audio_module(SigilVM *vm)
692
SIGIL_ARITY_RANGE(1, 4), "Play sound effect"); 693
sigil_module_register_native(vm, "stop-all-sounds", native_stop_all_sounds, 694
SIGIL_ARITY_EXACT(0), "Stop all sound effects");+695
sigil_module_register_native(vm, "%sound-from-buffer", native_sound_from_buffer,+696
SIGIL_ARITY_EXACT(2), "A sound from float32 PCM in memory (internal)");+697
sigil_module_register_native(vm, "%set-sounds-volume", native_set_sounds_volume,+698
SIGIL_ARITY_EXACT(1), "The gain of play-sound sounds (internal)"); 699
700
/* Music streaming */ 701
sigil_module_register_native(vm, "play-music", native_play_music,@@ -672,6 +731,8 @@ void sigil__init_sigil_audio_module(SigilVM *vm)
731
sigil_module_export(vm, "sound?"); 732
sigil_module_export(vm, "play-sound"); 733
sigil_module_export(vm, "stop-all-sounds");+734
sigil_module_export(vm, "%sound-from-buffer");+735
sigil_module_export(vm, "%set-sounds-volume"); 736
sigil_module_export(vm, "play-music"); 737
sigil_module_export(vm, "stop-music"); 738
sigil_module_export(vm, "pause-music");src/sigil/audio.sglmodified
@@ -32,6 +32,9 @@
32
;; Native functions exported directly by audio.c: 33
;; audio-setup, audio-shutdown, audio-initialized?, audio-backend, audio-device? 34
;; load-sound, sound?, play-sound, stop-all-sounds+35
;; %sound-from-buffer, %set-sounds-volume (internal: the facade's cues)+36
;; audio-stream-underruns-sounding, audio-stream-rate,+37
;; %audio-stream-set-rate! (internal; set-audio-stream-rate! below) 38
;; play-music, stop-music, pause-music, resume-music 39
;; music-playing?, set-music-volume 40
;; set-master-volume, mute-audio, unmute-audio, audio-muted?@@ -43,6 +46,7 @@
46
write-ogg 47
;; Streaming sink (Scheme wrapper with keyword args; rest native) 48
open-audio-stream+49
set-audio-stream-rate! 50
;; Playlist utilities (Scheme-defined) 51
wait-music-end 52
play-playlist)@@ -95,17 +99,33 @@
99
;;; this is safety margin, not latency. Shrink it 100
;;; (e.g. 2048) for live-coding reactivity. 101
;;; volume: Per-stream gain, 0.0-1.0. Default 1.0.−98
;;;−99
;;; The stream's sample rate MUST match the device's configured−100
;;; rate (44100 today). No resampling is performed.+102
;;; rate: The rate the producer renders at, Hz. Default #f: the+103
;;; device's rate (44100 today). Below it, the stream is+104
;;; upsampled (see set-audio-stream-rate!). 105
;;; 106
;;; Audio must be set up (`audio-setup`) first. Up to 4 streams may 107
;;; be open simultaneously. 108
(define (open-audio-stream 109
(keys: (channels 2) 110
(buffer-frames 16384)−107
(volume 1.0)))−108
(%open-audio-stream channels buffer-frames volume))+111
(volume 1.0)+112
(rate #f)))+113
(let ((s (%open-audio-stream channels buffer-frames volume)))+114
(when (and s rate)+115
(set-audio-stream-rate! s rate))+116
s))+117
+118
;;; Feed STREAM at RATE Hz from now on. Frames pushed after this call are+119
;;; rendered at RATE, and the audio thread upsamples them to the device's+120
;;; rate with a 4-tap cubic; frames already queued play at the rate they+121
;;; were pushed at. RATE above the device's is clamped to it. Depth, room+122
;;; and capacity count the stream's own frames. Returns #t when accepted,+123
;;; #f for a bad rate or when 8 changes are already waiting for the audio+124
;;; thread to reach them.+125
(define (set-audio-stream-rate! s rate)+126
(and (integer? rate)+127
(> rate 0)+128
(%audio-stream-set-rate! s rate))) 129
130
;; ============================================================ 131
;; Playlist Utilitiessrc/sigil/audio/sink.sglmodified
@@ -31,6 +31,35 @@
31
;;; (push-audio-samples s pcm n-frames) ; interleaved f32 32
;;; (audio-stream-depth s)) ; backpressure accounting 33
;;; ```+34
;;;+35
;;; ## Streams below the device's rate+36
;;;+37
;;; A producer that renders below the device's rate (a synth stepped down by+38
;;; (sigil audio ladder)) passes `rate:` to open-audio-stream, or calls+39
;;; set-audio-stream-rate! when it steps; the sink upsamples. Frames already+40
;;; queued keep the rate they were pushed at, so a step needs no reopen.+41
;;;+42
;;; ## Cues+43
;;;+44
;;; A cue is a short sound a game triggers: a tap, a hit. Pushed into a stream+45
;;; it would be heard one ring-depth late (a 256 ms ring is a 256 ms delay).+46
;;; Load it once and play it by handle instead; it is mixed at output time:+47
;;;+48
;;; ```scheme+49
;;; (define tap (load-audio-cue (render-tap rate) channels: 1))+50
;;; (play-audio-cue tap volume: 0.8 pan: -0.2)+51
;;; ```+52
;;;+53
;;; On the web the cue is mixed in an AudioWorklet in the next render quantum+54
;;; (2.7 ms at 48 kHz); natively it plays through play-sound, mixed in the+55
;;; device callback. Neither waits behind a stream's ring.+56
;;;+57
;;; ## Underruns+58
;;;+59
;;; audio-stream-underruns counts output frames a stream could not fill.+60
;;; audio-stream-underruns-sounding is the part a listener heard as a gap:+61
;;; the stream ran dry within 128 frames of a sample above -80 dB. The rest+62
;;; (audio-stream-underruns-idle) is a quiet or unfed stream running dry. 63
64
(define-library (sigil audio sink) 65
(import (sigil core))@@ -55,16 +84,29 @@
84
audio-stream-capacity 85
audio-stream-channels 86
audio-stream-underruns+87
audio-stream-underruns-sounding+88
audio-stream-underruns-idle+89
audio-stream-rate+90
set-audio-stream-rate! 91
audio-stream? 92
audio-stream-closed? 93
close-audio-stream! 94
set-audio-stream-volume!+95
;; Cues: loaded once, mixed at output time+96
load-audio-cue+97
audio-cue?+98
play-audio-cue+99
unload-audio-cue!+100
stop-audio-cues!+101
set-audio-cue-volume!+102
audio-cue-mode+103
audio-cue-lag 104
;; Portable f32 PCM buffer builder (available on both backends) 105
make-float-buffer) 106
107
;; The wasm arm gets every export from (sigil browser audio-sink). The 108
;; native arm re-exports the sink ops from (sigil audio) and supplies the−67
;; context shims below.+109
;; context shims and the cues below. 110
(cond-expand 111
(wasm) 112
(else@@ -83,12 +125,81 @@
125
(define (audio-context-resume ctx) 126
#t) 127
−86
;;; The context sample rate in Hz. The device runs at a fixed 44100; the−87
;;; sink ring assumes the same rate (no resampling).+128
;;; The context sample rate in Hz. The device runs at a fixed 44100;+129
;;; a stream fed at another rate says so with set-audio-stream-rate!. 130
(define (audio-context-sample-rate ctx) 131
44100) 132
133
;;; The context run state: one of 'suspended, 'running, 'closed. 134
;;; Native contexts are always 'running once opened. 135
(define (audio-context-state ctx)−94
'running)))))+136
'running)+137
+138
;;; The underrun frames nobody heard: a quiet (or never fed) stream ran dry.+139
(define (audio-stream-underruns-idle s)+140
(- (audio-stream-underruns s) (audio-stream-underruns-sounding s)))+141
+142
;; ------------------------------------------------------------+143
;; Cues, native: a sound in memory played through play-sound,+144
;; mixed in the device callback. A cue handle is+145
;; #(native-audio-cue sound live?).+146
;; ------------------------------------------------------------+147
+148
(define cue-tag 'native-audio-cue)+149
+150
;; Every cue ever loaded stays reachable: play-sound's slot points+151
;; at the sound's samples, and a sound collected while it plays+152
;; would be read after it is freed. Cues are small and few.+153
(define *cues* '())+154
+155
;;; Is x a cue handle from load-audio-cue?+156
(define (audio-cue? x)+157
(and (vector? x)+158
(= (vector-length x) 3)+159
(eq? (vector-ref x 0) cue-tag)))+160
+161
;;; Load a cue: interleaved float32 PCM at the context's sample rate,+162
;;; mono (channels: 1) or stereo (channels: 2, the default). The PCM+163
;;; is copied. Returns a cue handle, or #f.+164
(define (load-audio-cue bv (keys: (channels 2)))+165
(let ((snd (and (bytevector? bv) (%sound-from-buffer bv channels))))+166
(and snd+167
(let ((cue (vector cue-tag snd #t)))+168
(set! *cues* (cons cue *cues*))+169
cue))))+170
+171
;;; Play a cue now. volume 0.0-1.0; pan -1.0 (left) to 1.0 (right).+172
;;; Returns #t if the play was accepted (16 sounds play at once+173
;;; natively; a play past that is refused).+174
(define (play-audio-cue cue (keys: (volume 1.0) (pan 0.0)))+175
(and (audio-cue? cue)+176
(vector-ref cue 2)+177
(play-sound (vector-ref cue 1) (* 1.0 volume) (* 1.0 pan) #f)))+178
+179
;;; Forget a cue: later plays are refused. Natively its PCM stays in+180
;;; memory until the program exits (see *cues*).+181
(define (unload-audio-cue! cue)+182
(and (audio-cue? cue)+183
(vector-ref cue 2)+184
(begin (vector-set! cue 2 #f) #t)))+185
+186
;;; Silence every cue now playing. Natively this is stop-all-sounds:+187
;;; it stops every play-sound sound, cue or not.+188
(define (stop-audio-cues!)+189
(stop-all-sounds)+190
#t)+191
+192
;;; The master cue gain, 0.0-1.0. Natively it scales every play-sound+193
;;; sound, including ones already playing.+194
(define (set-audio-cue-volume! v)+195
(%set-sounds-volume (* 1.0 v))+196
#t)+197
+198
;;; How cues are played: 'device natively (the device callback).+199
(define (audio-cue-mode)+200
'device)+201
+202
;;; The measured wait of the last cue play, frames. Only the web+203
;;; worklet measures one; natively #f.+204
(define (audio-cue-lag)+205
#f)))))test/test-sink-rate-split.sgladded
@@ -0,0 +1,172 @@
+1
;;; The native sink's resampling, rate changes and underrun split, and the+2
;;; facade's native cues.+3
;;;+4
;;; No device is opened: %audio-stream-drain-for-test runs the audio+5
;;; thread's mix over a bytevector, so every frame is deterministic. Each+6
;;; test closes its streams (the mix drains every open one).+7
+8
(import (sigil test)+9
(sigil math)+10
(sigil audio)+11
(only (sigil audio sink) load-audio-cue audio-cue? play-audio-cue+12
unload-audio-cue! audio-cue-mode audio-cue-lag+13
audio-stream-underruns-idle))+14
+15
(define DEVICE 44100)+16
(define PI 3.141592653589793)+17
+18
;; n stereo frames, frame i = (f i) on both channels+19
(define (frames n f)+20
(let ((v (make-vector (* 2 n) 0.0)))+21
(let loop ((i 0))+22
(when (< i n)+23
(let ((x (* 1.0 (f i))))+24
(vector-set! v (* 2 i) x)+25
(vector-set! v (+ (* 2 i) 1) (- x)))+26
(loop (+ i 1))))+27
(make-float-buffer v)))+28
+29
;; drain n device frames; answers a vector of the left channel+30
(define (drain n)+31
(let ((out (make-bytevector (* n 2 4) 0)))+32
(%audio-stream-drain-for-test out n 2)+33
(let ((v (make-vector n 0.0)))+34
(let loop ((i 0))+35
(when (< i n)+36
(vector-set! v i (%bv-f32-ref out (* i 8)))+37
(loop (+ i 1))))+38
v)))+39
+40
(define (right-of n)+41
(let ((out (make-bytevector (* n 2 4) 0)))+42
(%audio-stream-drain-for-test out n 2)+43
(%bv-f32-ref out 4)))+44
+45
;; zero crossings over v[a, b) as a frequency at the device's rate+46
(define (freq v a b)+47
(let loop ((i (+ a 1)) (n 0))+48
(if (>= i b)+49
(* (/ n 2.0) (/ DEVICE (- b a)))+50
(loop (+ i 1)+51
(if (eq? (< (vector-ref v (- i 1)) 0) (< (vector-ref v i) 0)) n (+ n 1))))))+52
+53
(define (sine rate)+54
(lambda (i) (* 0.2 (sin (/ (* 2 PI 440 i) rate)))))+55
+56
(test-group "underruns split by what the listener lost"+57
(test "a stream never fed runs dry idle"+58
(let ((s (open-audio-stream buffer-frames: 4096)))+59
(drain 256)+60
(assert-equal 256 (audio-stream-underruns s))+61
(assert-equal 0 (audio-stream-underruns-sounding s))+62
(assert-equal 256 (audio-stream-underruns-idle s))+63
(close-audio-stream! s)))+64
+65
(test "dry after silence is idle; dry after sound is sounding"+66
(let ((s (open-audio-stream buffer-frames: 4096)))+67
(push-audio-samples s (frames 256 (lambda (i) 0.0)) 256)+68
(drain 512)+69
(assert-equal 256 (audio-stream-underruns s))+70
(assert-equal 0 (audio-stream-underruns-sounding s))+71
(push-audio-samples s (frames 256 (sine DEVICE)) 256)+72
(drain 512)+73
(assert-equal 512 (audio-stream-underruns s))+74
(assert-equal 256 (audio-stream-underruns-sounding s))+75
(close-audio-stream! s)))+76
+77
(test "sound that faded to silence 200 frames before the ring ran dry is idle"+78
(let ((s (open-audio-stream buffer-frames: 4096)))+79
(push-audio-samples s (frames 128 (sine DEVICE)) 128)+80
(push-audio-samples s (frames 200 (lambda (i) 0.0)) 200)+81
(drain 428)+82
(assert-equal 100 (audio-stream-underruns s))+83
(assert-equal 0 (audio-stream-underruns-sounding s))+84
(close-audio-stream! s)))+85
+86
(test "sound at volume 0 is not heard: dry after it is idle"+87
(let ((s (open-audio-stream buffer-frames: 4096 volume: 0.0)))+88
(push-audio-samples s (frames 256 (sine DEVICE)) 256)+89
(drain 512)+90
(assert-equal 256 (audio-stream-underruns s))+91
(assert-equal 0 (audio-stream-underruns-sounding s))+92
(close-audio-stream! s))))+93
+94
(test-group "a stream fed below the device's rate"+95
(test "a new stream feeds at the device's rate"+96
(let ((s (open-audio-stream)))+97
(assert-equal DEVICE (audio-stream-rate s))+98
(close-audio-stream! s)))+99
+100
(test "at 22050 a 440 Hz tone plays at 440 Hz, for twice its frames"+101
(let* ((s (open-audio-stream buffer-frames: 16384 rate: 22050))+102
(n 4410))+103
(assert-equal 22050 (audio-stream-rate s))+104
(assert-equal n (push-audio-samples s (frames n (sine 22050)) n))+105
(let* ((v (drain 9000))+106
(under (audio-stream-underruns s))+107
(played (- 9000 under)))+108
;; 4410 frames at half the rate: 8820 at the output, less the two+109
;; the cubic needs ahead of its last position+110
(assert-true (<= (abs (- played 8820)) 4))+111
(assert-true (< (abs (- (freq v 100 8700) 440)) 5))+112
;; against the ideal tone at the output rate+113
(let loop ((i 64) (err 0.0))+114
(if (< i 8700)+115
(loop (+ i 1) (+ err (square (- (vector-ref v i) ((sine DEVICE) i)))))+116
(assert-true (< (sqrt (/ err (- 8700 64))) 0.0002)))))+117
(close-audio-stream! s)))+118
+119
(test "the channels stay apart through the upsampler"+120
(let ((s (open-audio-stream buffer-frames: 16384 rate: 22050)))+121
(push-audio-samples s (frames 1000 (lambda (i) 0.3)) 1000)+122
(drain 64)+123
(assert-true (< (abs (+ 0.3 (right-of 1))) 1e-6))+124
(close-audio-stream! s)))+125
+126
(test "a rate change lands at the head it was asked at"+127
(let ((s (open-audio-stream buffer-frames: 16384)))+128
(push-audio-samples s (frames 1024 (lambda (i) 0.25)) 1024)+129
(assert-true (set-audio-stream-rate! s 22050))+130
(push-audio-samples s (frames 1024 (lambda (i) 0.5)) 1024)+131
(let* ((v (drain 4000))+132
(played (- 4000 (audio-stream-underruns s))))+133
;; the first 1024 frames are the device-rate frames, copied exactly+134
(assert-equal 0.25 (vector-ref v 0))+135
(assert-equal 0.25 (vector-ref v 1023))+136
;; then 1024 frames at half the rate: about 2048 at the output+137
(assert-true (<= (abs (- played (+ 1024 2048))) 4))+138
(assert-true (< (abs (- (vector-ref v 2000) 0.5)) 1e-6)))+139
(close-audio-stream! s)))+140
+141
(test "a rate above the device's is clamped; zero is refused"+142
(let ((s (open-audio-stream)))+143
(assert-true (set-audio-stream-rate! s 96000))+144
(assert-equal DEVICE (audio-stream-rate s))+145
(assert-false (set-audio-stream-rate! s 0))+146
(close-audio-stream! s)))+147
+148
(test "eight changes may wait for the audio thread; a ninth is refused"+149
(let ((s (open-audio-stream)))+150
(let loop ((i 0) (ok '()))+151
(if (< i 9)+152
(loop (+ i 1) (cons (set-audio-stream-rate! s 22050) ok))+153
(assert-equal '(#f #t #t #t #t #t #t #t #t) ok)))+154
(close-audio-stream! s))))+155
+156
(test-group "cues on native"+157
(test "a cue loads from float32 PCM and plays through the device's sounds"+158
(let ((cue (load-audio-cue (frames 64 (lambda (i) 0.5)))))+159
(assert-true (audio-cue? cue))+160
(assert-equal 'device (audio-cue-mode))+161
(assert-false (audio-cue-lag))+162
(assert-true (play-audio-cue cue volume: 0.5))))+163
+164
(test "an unloaded cue refuses to play"+165
(let ((cue (load-audio-cue (frames 64 (lambda (i) 0.5)) channels: 2)))+166
(assert-true (unload-audio-cue! cue))+167
(assert-false (play-audio-cue cue))+168
(assert-false (unload-audio-cue! cue))))+169
+170
(test "a load of no PCM is refused"+171
(assert-false (load-audio-cue (make-bytevector 0 0)))+172
(assert-false (load-audio-cue "not pcm"))))