diff options
| author | Cliff Click <cliffc@acm.org> | 2026-11-12 13:16:00 -0800 |
|---|---|---|
| committer | Cliff Click <cliffc@acm.org> | 2026-11-12 13:52:46 -0800 |
| commit | 467338ee01565234762db8077c067df42c3b40a8 (patch) | |
| tree | 42c44a77da87c4811b1eb7611d0952dc2c55e022 /chapter31/src | |
| parent | 0720fd8ed0aa92248622a9f3c3ebd522b002fd91 (diff) | |
| download | Simple-467338ee01565234762db8077c067df42c3b40a8.tar.gz Simple-467338ee01565234762db8077c067df42c3b40a8.zip | |
Add audio, overhaul makefiles
Adds simple audio support to Simple
Overhaul makefiles; remove common boilerplate; make them more uniform
Diffstat (limited to 'chapter31/src')
| -rw-r--r-- | chapter31/src/main/c/simple_audio.c | 40 | ||||
| -rw-r--r-- | chapter31/src/main/c/simple_audio.h | 16 | ||||
| -rw-r--r-- | chapter31/src/main/c/simple_miniaudio.c | 123 | ||||
| -rw-r--r-- | chapter31/src/main/smp/sys.smp | 1 | ||||
| -rw-r--r-- | chapter31/src/main/smp/sys/audio.smp | 29 | ||||
| -rw-r--r-- | chapter31/src/test/c/audio_test.c | 48 | ||||
| -rw-r--r-- | chapter31/src/test/java/com/seaofnodes/simple/AudioTest.java | 94 | ||||
| -rw-r--r-- | chapter31/src/test/java/com/seaofnodes/simple/Chapter25Test.java | 2 | ||||
| -rw-r--r-- | chapter31/src/test/java/com/seaofnodes/simple/DrawTest.java | 3 | ||||
| -rw-r--r-- | chapter31/src/test/java/com/seaofnodes/simple/StreamIOTest.java | 1 | ||||
| -rw-r--r-- | chapter31/src/test/java/com/seaofnodes/simple/TestC.java | 1 |
11 files changed, 357 insertions, 1 deletions
diff --git a/chapter31/src/main/c/simple_audio.c b/chapter31/src/main/c/simple_audio.c new file mode 100644 index 00000000..b8ed62de --- /dev/null +++ b/chapter31/src/main/c/simple_audio.c @@ -0,0 +1,40 @@ +#include "simple_audio.h" +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <dlfcn.h> + +static const struct simple_audio_api *api; +static void *library; + +int32_t simple_audio_open(void) { + const char *path = getenv("SIMPLE_MINIAUDIO"); + if (path && strcmp(path,"off")==0) return -1; + if (!api) { + if (!path || !*path) return -1; // Optional: games may run silently. + library = dlopen(path,RTLD_NOW | RTLD_LOCAL); + if (!library) { + fprintf(stderr,"Simple audio: cannot load backend '%s': %s\n",path,dlerror()); + return -1; + } + const struct simple_audio_api *(*get_api)(void) = + (const struct simple_audio_api *(*)(void))dlsym(library,"simple_miniaudio_api"); + const struct simple_audio_api *candidate = get_api ? get_api() : NULL; + if (!candidate || candidate->version!=SIMPLE_AUDIO_VERSION || !candidate->open || + !candidate->load || !candidate->play || !candidate->stop || + !candidate->playing || !candidate->close) { + fprintf(stderr,"Simple audio: incompatible backend; rebuild with make audio.\n"); + dlclose(library); + library=NULL; + return -1; + } + api=candidate; + atexit(api->close); + } + return api->open(); +} +int32_t simple_audio_load(const int16_t *samples, uint32_t count) { return api ? api->load(samples,count) : -1; } +int32_t simple_audio_play(int32_t sound) { return api ? api->play(sound) : -1; } +int32_t simple_audio_stop(void) { return api ? api->stop() : 0; } +int32_t simple_audio_playing(void) { return api ? api->playing() : 0; } +int32_t simple_audio_close(void) { if (api) api->close(); return 0; } diff --git a/chapter31/src/main/c/simple_audio.h b/chapter31/src/main/c/simple_audio.h new file mode 100644 index 00000000..77cce530 --- /dev/null +++ b/chapter31/src/main/c/simple_audio.h @@ -0,0 +1,16 @@ +#ifndef SIMPLE_AUDIO_H +#define SIMPLE_AUDIO_H +#include <stdint.h> + +// Main-thread API. Samples are copied; no Simple heap pointer is retained. +struct simple_audio_api { + int32_t version; + int32_t (*open)(void); + int32_t (*load)(const int16_t *, uint32_t); + int32_t (*play)(int32_t); + int32_t (*stop)(void); + int32_t (*playing)(void); + void (*close)(void); +}; +#define SIMPLE_AUDIO_VERSION 1 +#endif diff --git a/chapter31/src/main/c/simple_miniaudio.c b/chapter31/src/main/c/simple_miniaudio.c new file mode 100644 index 00000000..03dbadd7 --- /dev/null +++ b/chapter31/src/main/c/simple_miniaudio.c @@ -0,0 +1,123 @@ +#include "simple_audio.h" +#define MA_NO_DECODING +#define MA_NO_ENCODING +#define MA_NO_RESOURCE_MANAGER +#define MA_NO_NODE_GRAPH +#define MA_NO_ENGINE +#define MINIAUDIO_IMPLEMENTATION +#include <miniaudio.h> +#include <math.h> +#include <stdlib.h> +#include <string.h> +#include <stdio.h> + +#define RATE 48000 +#define SLOTS 32 +static ma_device device; +static ma_mutex mutex; +static int opened, active; +static uint32_t cursor; +static struct { int16_t *pcm; uint32_t count; } sounds[SLOTS]; + +// Only native-owned immutable PCM is read on the audio thread. The lock covers +// a bounded copy, never allocation, synthesis, device calls, or Simple code. +static void output(ma_device *unused, void *out, const void *in, ma_uint32 frames) { + (void)unused; (void)in; + memset(out,0,frames*sizeof(int16_t)); + ma_mutex_lock(&mutex); + if (active) { + uint32_t count=sounds[active].count-cursor; + if (count>frames) count=frames; + memcpy(out,sounds[active].pcm+cursor,count*sizeof(int16_t)); + cursor+=count; + if (cursor==sounds[active].count) active=0; + } + ma_mutex_unlock(&mutex); +} +static int32_t load(const int16_t *pcm, uint32_t count) { + if (!opened || !pcm || !count || count>RATE*10) return -1; + int slot; + for (slot=4; slot<SLOTS && sounds[slot].pcm; ++slot) {} + if (slot==SLOTS) return -1; + int16_t *copy=malloc(count*sizeof(*copy)); + if (!copy) return -1; + memcpy(copy,pcm,count*sizeof(*copy)); + ma_mutex_lock(&mutex); + sounds[slot].pcm=copy; sounds[slot].count=count; + ma_mutex_unlock(&mutex); + return slot; +} +static int32_t play(int32_t sound) { + if (!opened || sound<=0 || sound>=SLOTS || !sounds[sound].pcm) return -1; + ma_mutex_lock(&mutex); + active=sound; cursor=0; + ma_mutex_unlock(&mutex); + return 0; +} +static int32_t stop(void) { + if (opened) { ma_mutex_lock(&mutex); active=0; ma_mutex_unlock(&mutex); } + return 0; +} +static int32_t playing(void) { + if (!opened) return 0; + ma_mutex_lock(&mutex); + int result=active!=0; + ma_mutex_unlock(&mutex); + return result; +} +static void close_audio(void) { + if (!opened) return; + ma_device_uninit(&device); // Joins callback before freeing buffers/mutex. + ma_mutex_uninit(&mutex); + for (int i=1;i<SLOTS;++i) { + free(sounds[i].pcm); sounds[i].pcm=NULL; sounds[i].count=0; + } + opened=0; active=0; cursor=0; +} +static int tone(int slot, int descending) { + uint32_t note=slot==1 ? RATE*7/100 : RATE*16/100; + int notes=slot==1 ? 1 : 3; + sounds[slot].count=note*notes; + sounds[slot].pcm=calloc(note*notes,sizeof(int16_t)); + if (!sounds[slot].pcm) return -1; + const double pitches[3]={523.251,659.255,783.991}; + for (int n=0;n<notes;++n) { + double hz=slot==1 ? 440.0 : pitches[descending ? 2-n : n]; + for (uint32_t i=0;i<note;++i) { + double t=(double)i/note; + double envelope=sin(3.141592653589793*t); + sounds[slot].pcm[n*note+i]=(int16_t)(3200*envelope*envelope*sin(6.283185307179586*hz*i/RATE)); + } + } + return 0; +} +static int32_t open_audio(void) { + if (opened) return 0; + if (ma_mutex_init(&mutex)!=MA_SUCCESS) return -1; + ma_device_config config=ma_device_config_init(ma_device_type_playback); + config.playback.format=ma_format_s16; + config.playback.channels=1; + config.sampleRate=RATE; + config.dataCallback=output; +#ifdef SIMPLE_AUDIO_TEST_NULL + ma_backend backend=ma_backend_null; + ma_result result=ma_device_init_ex(&backend,1,NULL,&config,&device); +#else + ma_result result=ma_device_init(NULL,&config,&device); +#endif + if (result!=MA_SUCCESS) { + ma_mutex_uninit(&mutex); + fprintf(stderr,"Simple audio: no playback device (%d); continuing silently.\n",result); + return -1; + } + opened=1; + if (tone(1,0) || tone(2,0) || tone(3,1) || ma_device_start(&device)!=MA_SUCCESS) { + close_audio(); return -1; + } + return 0; +} +static const struct simple_audio_api api={SIMPLE_AUDIO_VERSION,open_audio,load,play,stop,playing,close_audio}; +#ifdef _WIN32 +__declspec(dllexport) +#endif +const struct simple_audio_api *simple_miniaudio_api(void) { return &api; } diff --git a/chapter31/src/main/smp/sys.smp b/chapter31/src/main/smp/sys.smp index 3ef19099..40a4b955 100644 --- a/chapter31/src/main/smp/sys.smp +++ b/chapter31/src/main/smp/sys.smp @@ -2,6 +2,7 @@ // top-level default standard library import val adt = adt; // Abstract Data Types +val audio = audio; // Optional background sound (miniaudio) val ary = ary; // Array utilities val aryi64 = aryi64; // Variable sized array of i64 val aryu8 = aryu8; // Variable sized array of u8 diff --git a/chapter31/src/main/smp/sys/audio.smp b/chapter31/src/main/smp/sys/audio.smp new file mode 100644 index 00000000..89e6dfa9 --- /dev/null +++ b/chapter31/src/main/smp/sys/audio.smp @@ -0,0 +1,29 @@ +// Optional background audio. All API calls belong on the game/main thread. +int OK = 0; +int ERROR = -1; +int BOOP = 1; +int WIN = 2; +int LOSE = 3; +int RATE = 48000; +{ i32 } simple_audio_open = "C"; +{ i64 u32 -> i32 } simple_audio_load = "C"; +{ i32 -> i32 } simple_audio_play = "C"; +{ i32 } simple_audio_stop = "C"; +{ i32 } simple_audio_playing = "C"; +{ i32 } simple_audio_close = "C"; +// Missing/disabled backend returns ERROR quietly; the game may remain silent. +val open = { -> simple_audio_open(); }; +// Mono signed 16-bit PCM at RATE, up to ten seconds. Copies into native memory. +// Load at startup: up to 28 custom handles, valid until close, then reload. +val load = { i16[~] samples -> + i64 ptr = samples; + simple_audio_load(ptr,samples#); +}; +// Starts/restarts immediately, replacing the previous sound; never waits for it. +val play = { int sound -> simple_audio_play(sound); }; +val boop = { -> simple_audio_play(BOOP); }; +val win = { -> simple_audio_play(WIN); }; +val lose = { -> simple_audio_play(LOSE); }; +val stop = { -> simple_audio_stop(); }; +val playing = { -> simple_audio_playing(); }; +val close = { -> simple_audio_close(); }; diff --git a/chapter31/src/test/c/audio_test.c b/chapter31/src/test/c/audio_test.c new file mode 100644 index 00000000..f82504c3 --- /dev/null +++ b/chapter31/src/test/c/audio_test.c @@ -0,0 +1,48 @@ +// Explicit optional integration checks: real miniaudio thread, silent device. +#define SIMPLE_AUDIO_TEST_NULL +#include "../../main/c/simple_miniaudio.c" +#include <assert.h> + +int main(void) { + assert(api.play(1)==-1 && api.playing()==0); + assert(api.stop()==0); + api.close(); + for (int pass=0;pass<2;++pass) { + assert(api.open()==0 && api.open()==0); + assert(api.load(NULL,1)==-1); + int16_t pcm[RATE]={0}; + assert(api.load(pcm,0)==-1 && api.load(pcm,RATE*10+1)==-1); + int sound=api.load(pcm,RATE); + assert(sound==4); + pcm[0]=12345; + assert(sounds[sound].pcm[0]==0); + assert(api.play(-1)==-1 && api.play(0)==-1 && api.play(SLOTS)==-1); + assert(api.play(sound)==0 && api.playing()); + // A one-second sound returns while playback is still in progress. + assert(api.stop()==0 && !api.playing()); + for (int cue=1;cue<=3;++cue) { + assert(api.play(cue)==0); + int elapsed=0; + while (api.playing() && elapsed<2000) { ma_sleep(10); elapsed+=10; } + assert(!api.playing()); + } + for (int i=5;i<SLOTS;++i) assert(api.load(pcm,1)==i); + assert(api.load(pcm,1)==-1); + assert(api.play(sound)==0); + api.close(); // Join during playback, then reopen next pass. + api.close(); + assert(api.play(sound)==-1 && !api.playing()); + } + // The loss cue reverses exactly the same three softly enveloped notes. + assert(tone(1,0)==0 && tone(2,0)==0 && tone(3,1)==0); + uint32_t note=RATE*16/100; + for (int n=0;n<3;++n) + assert(memcmp(sounds[2].pcm+n*note,sounds[3].pcm+(2-n)*note,note*sizeof(int16_t))==0); + for (int i=1;i<=3;++i) { + assert(sounds[i].pcm[0]==0); + for (uint32_t j=0;j<sounds[i].count;++j) assert(abs(sounds[i].pcm[j])<=3200); + free(sounds[i].pcm); sounds[i].pcm=NULL; + } + puts("audio native checks passed"); + return 0; +} diff --git a/chapter31/src/test/java/com/seaofnodes/simple/AudioTest.java b/chapter31/src/test/java/com/seaofnodes/simple/AudioTest.java new file mode 100644 index 00000000..db8542d0 --- /dev/null +++ b/chapter31/src/test/java/com/seaofnodes/simple/AudioTest.java @@ -0,0 +1,94 @@ +package com.seaofnodes.simple; + +import com.seaofnodes.simple.util.Ary; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; +import org.junit.BeforeClass; +import org.junit.Test; +import static org.junit.Assert.*; + +// Loader and Simple/C ABI checks: no miniaudio download or audio device. +public class AudioTest { + private static String missing, playback; + + @BeforeClass public static void compilePrograms() throws Exception { + var paths=new Ary<>(new String[]{TestC.RELEASE_SYS_DIR}); + missing=TestC.compile(""" + val main = { -> + if( sys.audio.open()!=sys.audio.ERROR ) return 1; + if( sys.audio.boop()!=sys.audio.ERROR || sys.audio.playing() ) return 2; + sys.audio.stop(); sys.audio.close(); sys.audio.close(); + sys.io.p("silent fallback\\n"); + return 0; + }; + ""","audio_missing",paths,TestC.CALL_CONVENTION,null,null,-1); + playback=TestC.compile(""" + val main = { -> + if( sys.audio.open()!=sys.audio.OK ) return 1; + val pcm = new i16[3]; + pcm[0]=-32768; pcm[1]=123; pcm[2]=32767; + int sound = sys.audio.load(pcm); + if( sound!=4 || sys.audio.play(sound)!=0 || !sys.audio.playing() ) return 2; + if( sys.audio.boop()!=0 || sys.audio.win()!=0 || sys.audio.lose()!=0 ) return 3; + sys.audio.stop(); + if( sys.audio.playing() ) return 4; + sys.audio.close(); + return 0; + }; + ""","audio_playback",paths,TestC.CALL_CONVENTION,null,null,-1); + } + + private static String run(String exe, String backend) throws Exception { + ProcessBuilder pb=new ProcessBuilder(exe).redirectErrorStream(true); + if( backend==null ) pb.environment().remove("SIMPLE_MINIAUDIO"); + else pb.environment().put("SIMPLE_MINIAUDIO",backend); + Process child=pb.start(); + try { + assertTrue("audio program timed out",child.waitFor(10,TimeUnit.SECONDS)); + String out=new String(child.getInputStream().readAllBytes(),StandardCharsets.UTF_8); + assertEquals(out,0,child.exitValue()); + return out; + } finally { if( child.isAlive() ) child.destroyForcibly(); } + } + + @Test public void silentFallback() throws Exception { + assertEquals("silent fallback\n",run(missing,null)); + assertEquals("silent fallback\n",run(missing,"off")); + assertTrue(run(missing,"no-such-miniaudio-backend").contains("cannot load backend")); + } + + @Test public void nativeBoundary() throws Exception { + Path source=Path.of("build/objs/audio_stub.c"); + Path library=Path.of("build/objs/audio_stub"+(TestC.OS.startsWith("Windows") ? ".dll" : ".so")); + Files.writeString(source,""" + #include "simple_audio.h" + static int active; + static int32_t open_audio(void) { return 0; } + static int32_t load(const int16_t *p, uint32_t n) { + return n==3 && p[0]==-32768 && p[1]==123 && p[2]==32767 ? 4 : -1; + } + static int32_t play(int32_t cue) { active=1; return cue>=1 && cue<=4 ? 0 : -1; } + static int32_t stop(void) { active=0; return 0; } + static int32_t playing(void) { return active; } + static void close_audio(void) { active=0; } + static const struct simple_audio_api api={1,open_audio,load,play,stop,playing,close_audio}; + const struct simple_audio_api *simple_miniaudio_api(void) { return &api; } + """); + TestC.exec(30,"gcc","-shared","-fPIC","-Isrc/main/c",source.toString(),"-o",library.toString()); + assertEquals("",run(playback,library.toAbsolutePath().toString())); + } + + @Test public void incompatibleBackend() throws Exception { + Path source=Path.of("build/objs/audio_bad.c"); + Path library=Path.of("build/objs/audio_bad"+(TestC.OS.startsWith("Windows") ? ".dll" : ".so")); + Files.writeString(source,""" + #include "simple_audio.h" + static const struct simple_audio_api api={999}; + const struct simple_audio_api *simple_miniaudio_api(void) { return &api; } + """); + TestC.exec(30,"gcc","-shared","-fPIC","-Isrc/main/c",source.toString(),"-o",library.toString()); + assertTrue(run(missing,library.toAbsolutePath().toString()).contains("incompatible backend")); + } +} diff --git a/chapter31/src/test/java/com/seaofnodes/simple/Chapter25Test.java b/chapter31/src/test/java/com/seaofnodes/simple/Chapter25Test.java index 3b71bd2a..7f2b4a77 100644 --- a/chapter31/src/test/java/com/seaofnodes/simple/Chapter25Test.java +++ b/chapter31/src/test/java/com/seaofnodes/simple/Chapter25Test.java @@ -477,7 +477,7 @@ public class Chapter25Test { // Software drawing and the optional native graphics interface. assertArrayEquals(new String[]{"sys/adt", "sys/ary", - "sys/aryi64", "sys/aryu8", "sys/char", "sys/draw", "sys/gfx", "sys/io", "sys/libc", "sys/rt", "sys/scan", "sys/text"},sys_elf._deps); + "sys/aryi64", "sys/aryu8", "sys/audio", "sys/char", "sys/draw", "sys/gfx", "sys/io", "sys/libc", "sys/rt", "sys/scan", "sys/text"},sys_elf._deps); assertArrayEquals(new String[]{"sys"},sys_elf._units); assertSame("class:sys" ,sys_elf._clz._name); } diff --git a/chapter31/src/test/java/com/seaofnodes/simple/DrawTest.java b/chapter31/src/test/java/com/seaofnodes/simple/DrawTest.java index b3e8edd0..6eb261f1 100644 --- a/chapter31/src/test/java/com/seaofnodes/simple/DrawTest.java +++ b/chapter31/src/test/java/com/seaofnodes/simple/DrawTest.java @@ -75,12 +75,14 @@ public class DrawTest { _reset(board); if( _square(48,168)!=0 || _square(551,671)!=8 || _square(552,400)!=-1 || _square(100,167)!=-1 ) return 1; if( !_move(board,4) || board[4]!='X' || board[0]!='O' ) return 2; + if( _sound(board)!=sys.audio.BOOP ) return 10; if( _move(board,4) || _move(board,0) || _move(board,-1) || _move(board,9) ) return 3; val moves = "1879"; _reset(board); for( int i=0; i<moves#; i++ ) if( !_move(board,moves[i]-'1') ) return 4; if( _winner(board)!='X' || _move(board,1) ) return 5; + if( _sound(board)!=sys.audio.WIN ) return 11; val screen = new sys.draw.Canvas(600,780); _draw(screen,board,-1); if( screen.pixels[252*600+132]==screen.pixels[0] ) return 6; @@ -91,6 +93,7 @@ public class DrawTest { _reset(board); _move(board,0); _move(board,1); _move(board,3); if( _winner(board)!='O' ) return 8; + if( _sound(board)!=sys.audio.LOSE ) return 12; _draw(screen,board,-1); _reset(board); if( _finished(board) ) return 9; diff --git a/chapter31/src/test/java/com/seaofnodes/simple/StreamIOTest.java b/chapter31/src/test/java/com/seaofnodes/simple/StreamIOTest.java index 2aa2e3da..e47cd929 100644 --- a/chapter31/src/test/java/com/seaofnodes/simple/StreamIOTest.java +++ b/chapter31/src/test/java/com/seaofnodes/simple/StreamIOTest.java @@ -132,6 +132,7 @@ public class StreamIOTest extends TestC { gcc.add(runtimeObject("simple_crt")); gcc.add(runtimeObject("simple_args")); gcc.add(runtimeObject("simple_graphics")); + gcc.add(runtimeObject("simple_audio")); if( !OS.startsWith("Windows") ) gcc.add("-ldl"); gcc.addAll(new String[]{"-lm","-o",exe}); exec(TEST_TIMEOUT_SECONDS,gcc.asAry()); diff --git a/chapter31/src/test/java/com/seaofnodes/simple/TestC.java b/chapter31/src/test/java/com/seaofnodes/simple/TestC.java index 73795111..741ee13b 100644 --- a/chapter31/src/test/java/com/seaofnodes/simple/TestC.java +++ b/chapter31/src/test/java/com/seaofnodes/simple/TestC.java @@ -192,6 +192,7 @@ public abstract class TestC { params.addAll(objects); // The loader has no MiniFB dependency and opens nothing until gfx.open. params.add(runtimeObject("simple_graphics")); + params.add(runtimeObject("simple_audio")); if( !OS.startsWith("Windows") ) params.add("-ldl"); // Standalone Simple programs export `simple_main`; the small C // runtime owns native main and calls the Simple argument converter |
