Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

merge jovial-turing: macOS notepat app (SDL3) + slab subagent tracking

Conflicts resolved:
- fedac/native/src/ac-native.c: kept main's 120-frame boot animation
- fedac/native/src/audio.h: removed duplicated ACVoice/SampleVoice (now in synth_types.h)
- fedac/native/src/synth_types.h: added WAVE_HARP + harp_lp1 field from main
- slab/README.md: kept main's richer layout with swift/menubar scripts
- slab/bin/{claude-stop.sh,lid-ambient.sh}: kept main's lid-reactive.py ownership model

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+3561 -192
+30
fedac/native/macos/Info.plist.in
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 + <plist version="1.0"> 4 + <dict> 5 + <key>CFBundleExecutable</key> 6 + <string>notepat</string> 7 + <key>CFBundleIdentifier</key> 8 + <string>@@APP_ID@@</string> 9 + <key>CFBundleName</key> 10 + <string>@@APP_NAME@@</string> 11 + <key>CFBundleDisplayName</key> 12 + <string>@@APP_NAME@@</string> 13 + <key>CFBundleVersion</key> 14 + <string>0.1.0</string> 15 + <key>CFBundleShortVersionString</key> 16 + <string>0.1.0</string> 17 + <key>CFBundlePackageType</key> 18 + <string>APPL</string> 19 + <key>CFBundleSignature</key> 20 + <string>????</string> 21 + <key>CFBundleIconFile</key> 22 + <string>AppIcon</string> 23 + <key>LSMinimumSystemVersion</key> 24 + <string>11.0</string> 25 + <key>NSHighResolutionCapable</key> 26 + <true/> 27 + <key>NSSupportsAutomaticGraphicsSwitching</key> 28 + <true/> 29 + </dict> 30 + </plist>
+200
fedac/native/macos/Makefile
··· 1 + # ac-native macOS host Makefile 2 + # Links SDL3 directly (unlike the Linux build which dlopens SDL3 as a fallback). 3 + # Shares QuickJS sources with the Linux build at ../build/quickjs (fetched 4 + # by the Linux Makefile or manually). 5 + 6 + CC ?= clang 7 + BUILD := build 8 + # Target name carries the audio backend so switching AUDIO= between builds 9 + # (e.g. core ↔ sdl for latency A/B) doesn't leave a stale binary behind. 10 + AUDIO ?= core 11 + TARGET := $(BUILD)/ac-native-macos-$(AUDIO) 12 + 13 + # QuickJS sources live alongside the Linux build tree (shared fetch). 14 + QJSDIR := ../build/quickjs 15 + 16 + SDL3_CFLAGS := $(shell pkg-config --cflags sdl3) 17 + SDL3_LIBS_BASE := $(shell pkg-config --libs sdl3) 18 + 19 + CFLAGS := -O2 -Wall -Wextra -std=gnu11 -I. -I../src -I$(QJSDIR) $(SDL3_CFLAGS) 20 + # Carbon is always needed for RegisterEventHotKey; SDL3 comes via AUDIO block. 21 + LDFLAGS := -lm -framework Carbon 22 + 23 + # Audio backend: AUDIO=core (default) uses a direct CoreAudio AudioUnit 24 + # running the custom synth_core oscillators; AUDIO=sdl routes through SDL3's 25 + # audio stream so we can A/B the two latency profiles. Both implement 26 + # macos/audio.h so main.c + piece.c don't care which is live. 27 + ifeq ($(AUDIO),core) 28 + AUDIO_SRC := audio_coreaudio.c 29 + AUDIO_FRAMEWORKS := -framework AudioUnit -framework AudioToolbox \ 30 + -framework CoreAudio -framework CoreFoundation 31 + else ifeq ($(AUDIO),sdl) 32 + AUDIO_SRC := audio_sdl3.c 33 + AUDIO_FRAMEWORKS := 34 + else 35 + $(error AUDIO must be 'core' or 'sdl') 36 + endif 37 + 38 + HOST_SRCS := main.c piece.c $(AUDIO_SRC) 39 + HOST_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(HOST_SRCS)) 40 + SHARED_SRCS := ../src/synth_core.c 41 + SHARED_OBJS := $(patsubst ../src/%.c,$(BUILD)/shared-%.o,$(SHARED_SRCS)) 42 + LDFLAGS += $(SDL3_LIBS_BASE) $(AUDIO_FRAMEWORKS) 43 + 44 + # QuickJS core object set (must match what the Linux Makefile uses at line 104) 45 + QJS_SRCS := quickjs.c libunicode.c libregexp.c cutils.c libbf.c 46 + QJS_OBJS := $(patsubst %.c,$(BUILD)/qjs-%.o,$(QJS_SRCS)) 47 + 48 + .PHONY: all clean run app app-run compare install uninstall 49 + 50 + all: $(TARGET) 51 + 52 + # Paths for the .app bundle. APP_NAME / APP_ID can still be overridden if 53 + # we spin up a second bundle later — just keep the CFBundleIdentifier unique. 54 + APP_NAME ?= Notepat 55 + APP_ID ?= computer.aesthetic.notepat 56 + APP_DIR := $(BUILD)/$(APP_NAME).app 57 + PIECE_SRC := ../pieces/notepat.mjs 58 + LIB_SRC := ../../../system/public/aesthetic.computer/lib/percussion.mjs 59 + 60 + # SDL3 dylib: source path + install-name string the binary links against. 61 + SDL3_LIB_SRC := /opt/homebrew/lib/libSDL3.0.dylib 62 + SDL3_LIB_NAME := /opt/homebrew/opt/sdl3/lib/libSDL3.0.dylib 63 + 64 + # Code-signing identity. Default is ad-hoc (single dash), which is enough 65 + # for local-machine use. Override for distribution: 66 + # make app SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" 67 + # Use `security find-identity -v -p codesigning` to list available certs. 68 + SIGN_IDENTITY ?= - 69 + 70 + # Hardened runtime needs a real signing identity — with ad-hoc signing, the 71 + # main binary's CDHash doesn't match the dylib's and dyld refuses to load 72 + # libSDL3.0.dylib on launch ("different Team IDs" even though both are empty). 73 + # Only enable --options runtime when signing with a Developer ID. 74 + CS_RUNTIME := $(if $(filter-out -,$(SIGN_IDENTITY)),--options runtime,) 75 + 76 + # Install path. `make install` drops the signed .app into /Applications 77 + # so it picks up a proper Dock icon and is launchable from Spotlight. 78 + INSTALL_DIR ?= /Applications 79 + 80 + # App icon — 1024x1024 PNG generated by scripts/make-icon.py, then packaged 81 + # into AppIcon.icns via iconutil. Target stamps the .iconset dir and emits 82 + # the .icns in $(BUILD). 83 + ICON_SRC := assets/icon_1024.png 84 + ICON_SET_DIR := $(BUILD)/AppIcon.iconset 85 + ICON_ICNS := $(BUILD)/AppIcon.icns 86 + 87 + $(TARGET): $(HOST_OBJS) $(SHARED_OBJS) $(QJS_OBJS) | $(BUILD) 88 + $(CC) -o $@ $^ $(LDFLAGS) 89 + @echo "Built: $@ ($$(wc -c < $@ | tr -d ' ') bytes)" 90 + 91 + $(BUILD)/%.o: %.c | $(BUILD) 92 + $(CC) $(CFLAGS) -c -o $@ $< 93 + 94 + $(BUILD)/shared-%.o: ../src/%.c | $(BUILD) 95 + $(CC) $(CFLAGS) -c -o $@ $< 96 + 97 + # QuickJS objects: compiled with the same CONFIG_VERSION the Linux side uses. 98 + # Silence warnings QuickJS itself triggers — we don't own that code. 99 + $(BUILD)/qjs-%.o: $(QJSDIR)/%.c | $(BUILD) 100 + $(CC) $(CFLAGS) -DCONFIG_VERSION=\"0.8.0\" -Wno-unused-parameter -Wno-sign-compare -Wno-implicit-fallthrough -c -o $@ $< 101 + 102 + $(BUILD): 103 + mkdir -p $(BUILD) 104 + 105 + run: $(TARGET) 106 + $(TARGET) 107 + 108 + # Assemble a self-contained .app bundle with notepat + percussion lib + 109 + # libSDL3 embedded. After copying the dylib in, we rewrite install names 110 + # and rpaths so the binary resolves SDL3 through Contents/Frameworks rather 111 + # than brew's /opt/homebrew. Ad-hoc codesign is required on arm64 after any 112 + # install_name_tool modification — otherwise dyld refuses to load the 113 + # tampered Mach-O. 114 + # Build AppIcon.icns from the source PNG. Uses `sips` to downsample into 115 + # every size macOS expects, then `iconutil` to package. 116 + $(ICON_ICNS): $(ICON_SRC) | $(BUILD) 117 + rm -rf $(ICON_SET_DIR) 118 + mkdir -p $(ICON_SET_DIR) 119 + sips -z 16 16 $(ICON_SRC) --out $(ICON_SET_DIR)/icon_16x16.png > /dev/null 120 + sips -z 32 32 $(ICON_SRC) --out $(ICON_SET_DIR)/icon_16x16@2x.png > /dev/null 121 + sips -z 32 32 $(ICON_SRC) --out $(ICON_SET_DIR)/icon_32x32.png > /dev/null 122 + sips -z 64 64 $(ICON_SRC) --out $(ICON_SET_DIR)/icon_32x32@2x.png > /dev/null 123 + sips -z 128 128 $(ICON_SRC) --out $(ICON_SET_DIR)/icon_128x128.png > /dev/null 124 + sips -z 256 256 $(ICON_SRC) --out $(ICON_SET_DIR)/icon_128x128@2x.png > /dev/null 125 + sips -z 256 256 $(ICON_SRC) --out $(ICON_SET_DIR)/icon_256x256.png > /dev/null 126 + sips -z 512 512 $(ICON_SRC) --out $(ICON_SET_DIR)/icon_256x256@2x.png > /dev/null 127 + sips -z 512 512 $(ICON_SRC) --out $(ICON_SET_DIR)/icon_512x512.png > /dev/null 128 + cp $(ICON_SRC) $(ICON_SET_DIR)/icon_512x512@2x.png 129 + iconutil -c icns -o $(ICON_ICNS) $(ICON_SET_DIR) 130 + 131 + app: $(TARGET) $(PIECE_SRC) $(LIB_SRC) Info.plist.in $(SDL3_LIB_SRC) $(ICON_ICNS) 132 + rm -rf "$(APP_DIR)" 133 + mkdir -p "$(APP_DIR)/Contents/MacOS" 134 + mkdir -p "$(APP_DIR)/Contents/Resources/lib" 135 + mkdir -p "$(APP_DIR)/Contents/Frameworks" 136 + cp $(TARGET) "$(APP_DIR)/Contents/MacOS/notepat" 137 + cp $(PIECE_SRC) "$(APP_DIR)/Contents/Resources/piece.mjs" 138 + cp $(LIB_SRC) "$(APP_DIR)/Contents/Resources/lib/percussion.mjs" 139 + cp $(ICON_ICNS) "$(APP_DIR)/Contents/Resources/AppIcon.icns" 140 + sed -e 's/@@APP_NAME@@/$(APP_NAME)/g' -e 's/@@APP_ID@@/$(APP_ID)/g' \ 141 + Info.plist.in > "$(APP_DIR)/Contents/Info.plist" 142 + cp $(SDL3_LIB_SRC) "$(APP_DIR)/Contents/Frameworks/libSDL3.0.dylib" 143 + chmod +w "$(APP_DIR)/Contents/Frameworks/libSDL3.0.dylib" 144 + install_name_tool -id @rpath/libSDL3.0.dylib \ 145 + "$(APP_DIR)/Contents/Frameworks/libSDL3.0.dylib" 146 + install_name_tool -change $(SDL3_LIB_NAME) @rpath/libSDL3.0.dylib \ 147 + "$(APP_DIR)/Contents/MacOS/notepat" 148 + -install_name_tool -delete_rpath /opt/homebrew/lib \ 149 + "$(APP_DIR)/Contents/MacOS/notepat" 2>/dev/null 150 + install_name_tool -add_rpath @executable_path/../Frameworks \ 151 + "$(APP_DIR)/Contents/MacOS/notepat" 152 + codesign --force --sign "$(SIGN_IDENTITY)" \ 153 + "$(APP_DIR)/Contents/Frameworks/libSDL3.0.dylib" 154 + codesign --force $(CS_RUNTIME) --sign "$(SIGN_IDENTITY)" \ 155 + --deep "$(APP_DIR)" 156 + @echo "Bundle: $(APP_DIR)" 157 + @echo "--- install names ---" 158 + @otool -L "$(APP_DIR)/Contents/MacOS/notepat" | head -5 159 + 160 + app-run: app 161 + open "$(APP_DIR)" 162 + 163 + # Copy the signed .app into /Applications (or $(INSTALL_DIR)). Uses ditto 164 + # instead of cp -R so extended attributes + code signatures survive the 165 + # copy cleanly. Kicks LaunchServices so the new bundle is immediately 166 + # visible to Spotlight / Dock. 167 + install: app 168 + @if [ ! -w "$(INSTALL_DIR)" ]; then \ 169 + echo "[install] $(INSTALL_DIR) not writable — rerun with sudo or set INSTALL_DIR=..." >&2 ; exit 1 ; fi 170 + rm -rf "$(INSTALL_DIR)/$(APP_NAME).app" 171 + ditto "$(APP_DIR)" "$(INSTALL_DIR)/$(APP_NAME).app" 172 + /System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister \ 173 + -f "$(INSTALL_DIR)/$(APP_NAME).app" 2>/dev/null || true 174 + @echo "[install] $(INSTALL_DIR)/$(APP_NAME).app" 175 + @codesign --verify --verbose "$(INSTALL_DIR)/$(APP_NAME).app" 2>&1 | head -3 || true 176 + 177 + uninstall: 178 + rm -rf "$(INSTALL_DIR)/$(APP_NAME).app" 179 + @echo "[uninstall] removed $(INSTALL_DIR)/$(APP_NAME).app" 180 + 181 + clean: 182 + rm -rf $(BUILD) 183 + 184 + # A/B the two audio backends: build each, run AC_LATENCY_TEST, print both. 185 + # Lets us quantify whether SDL3's audio stream adds latency vs. a direct 186 + # CoreAudio AudioUnit. RUNS + BUFFER can be overridden on the command line. 187 + RUNS ?= 40 188 + BUFFER ?= 32 189 + PIECE ?= ../pieces/notepat.mjs 190 + KEY ?= c 191 + compare: 192 + @echo "=== backend: SDL3 ===" 193 + $(MAKE) -s AUDIO=sdl clean >/dev/null 194 + $(MAKE) -s AUDIO=sdl >/dev/null 195 + AC_AUDIO_BUFFER=$(BUFFER) AC_INJECT_KEY=$(KEY) AC_LATENCY_TEST=$(RUNS) $(TARGET) $(PIECE) 2>&1 | grep "^\[\(audio\|latency\)\]" 196 + @echo 197 + @echo "=== backend: CoreAudio ===" 198 + $(MAKE) -s AUDIO=core clean >/dev/null 199 + $(MAKE) -s AUDIO=core >/dev/null 200 + AC_AUDIO_BUFFER=$(BUFFER) AC_INJECT_KEY=$(KEY) AC_LATENCY_TEST=$(RUNS) $(TARGET) $(PIECE) 2>&1 | grep "^\[\(audio\|latency\)\]"
fedac/native/macos/assets/icon_1024.png

This is a binary file and will not be displayed.

+43
fedac/native/macos/audio.h
··· 1 + // audio.h — macOS SDL3 audio driver for the AC synth. 2 + // Wraps fedac/native/src/synth_core with an SDL3 device + callback. Public 3 + // API is similar to the Linux ALSA side but intentionally minimal — only 4 + // what the SDL3 host + piece.c currently need. 5 + #ifndef AC_MACOS_AUDIO_H 6 + #define AC_MACOS_AUDIO_H 7 + 8 + #include "synth_types.h" 9 + 10 + typedef struct Audio Audio; 11 + 12 + Audio *audio_init(void); 13 + void audio_destroy(Audio *a); 14 + 15 + // Basic oscillator voice. Mirrors synth_synth() in synth_core. 16 + uint64_t audio_synth(Audio *a, WaveType wave, double freq_hz, 17 + double duration_s, double volume, double attack_s, 18 + double decay_s, double pan); 19 + 20 + // Gun voice (all 12 presets). force_model: -1 = preset default, 0 = classic, 21 + // 1 = physical. pressure_scale multiplies gun_pressure (1.0 = preset default). 22 + uint64_t audio_synth_gun(Audio *a, GunPreset preset, double duration, 23 + double volume, double attack, double decay, 24 + double pan, double pressure_scale, int force_model); 25 + 26 + void audio_kill (Audio *a, uint64_t id, double fade_s); 27 + void audio_update(Audio *a, uint64_t id, double freq_hz, double volume, double pan); 28 + void audio_gun_set_param(Audio *a, uint64_t id, const char *key, double value); 29 + 30 + // Parse "sine"/"triangle"/"sawtooth"/"saw"/"square"/"noise"/"whistle"/"gun". 31 + WaveType audio_parse_wave(const char *s); 32 + 33 + // Keypress→sound latency instrumentation: 34 + // 1. Call audio_arm_latency() right before the input event that will trigger 35 + // a synth voice (trigger timestamp captured via SDL_GetTicksNS). 36 + // 2. The next audio callback whose output exceeds `threshold` in absolute 37 + // amplitude captures the emit timestamp. 38 + // 3. audio_latency_ns() returns the delta in nanoseconds, or 0 if the 39 + // sample hasn't landed yet. 40 + void audio_arm_latency(Audio *a, float threshold); 41 + uint64_t audio_latency_ns(Audio *a); 42 + 43 + #endif
+240
fedac/native/macos/audio_coreaudio.c
··· 1 + // audio_coreaudio.c — direct CoreAudio AudioUnit backend for macOS. 2 + // Wraps fedac/native/src/synth_core with a kAudioUnitSubType_DefaultOutput 3 + // AudioUnit and sets kAudioDevicePropertyBufferFrameSize on the hardware 4 + // device for the tightest realtime path we can take without writing a 5 + // HAL plugin. Chosen over SDL3 audio to test whether SDL's audio stream 6 + // indirection adds latency. 7 + 8 + #include "audio.h" 9 + #include "synth_core.h" 10 + 11 + #include <AudioUnit/AudioUnit.h> 12 + #include <AudioToolbox/AudioToolbox.h> 13 + #include <CoreAudio/CoreAudio.h> 14 + 15 + #include <pthread.h> 16 + #include <stdlib.h> 17 + #include <string.h> 18 + #include <stdio.h> 19 + #include <time.h> 20 + 21 + #define DRIVER_SAMPLE_RATE 48000 22 + 23 + static uint64_t now_ns(void) { 24 + struct timespec ts; 25 + clock_gettime(CLOCK_MONOTONIC, &ts); 26 + return (uint64_t)ts.tv_sec * 1000000000ULL + ts.tv_nsec; 27 + } 28 + 29 + struct Audio { 30 + AudioUnit au; 31 + SynthCore synth; 32 + pthread_mutex_t lock; 33 + uint64_t next_id; 34 + ACVoice voices[AUDIO_MAX_VOICES]; 35 + 36 + volatile float peak_out; 37 + volatile uint64_t samples_out; 38 + volatile uint64_t trigger_ns; 39 + volatile uint64_t emit_ns; 40 + volatile float emit_threshold; 41 + 42 + // Scratch interleaved stereo buffer for synth_render output (it writes 43 + // L/R interleaved; CoreAudio expects non-interleaved on the two buffers 44 + // of the AudioBufferList). 45 + float *scratch; 46 + int scratch_cap; 47 + 48 + int actual_frames; // negotiated device buffer size 49 + }; 50 + 51 + static OSStatus render_cb(void *inRef, 52 + AudioUnitRenderActionFlags *flags, 53 + const AudioTimeStamp *ts, 54 + UInt32 bus, 55 + UInt32 frames, 56 + AudioBufferList *io) { 57 + (void)flags; (void)ts; (void)bus; 58 + Audio *a = (Audio *)inRef; 59 + if (!io || io->mNumberBuffers < 2 || frames == 0) return noErr; 60 + 61 + float *L = (float *)io->mBuffers[0].mData; 62 + float *R = (float *)io->mBuffers[1].mData; 63 + if (!L || !R) return noErr; 64 + 65 + int need = (int)frames * 2; 66 + if (need > a->scratch_cap) { 67 + // Grow under lock — callback thread is the only caller, so this is 68 + // safe without synchronization; allocation cost is paid once per 69 + // buffer-size change (usually just the first callback). 70 + free(a->scratch); 71 + a->scratch = (float *)malloc((size_t)need * sizeof(float)); 72 + a->scratch_cap = a->scratch ? need : 0; 73 + if (!a->scratch) { 74 + memset(L, 0, frames * sizeof(float)); 75 + memset(R, 0, frames * sizeof(float)); 76 + return noErr; 77 + } 78 + } 79 + memset(a->scratch, 0, (size_t)need * sizeof(float)); 80 + synth_render(&a->synth, a->scratch, (int)frames); 81 + 82 + float peak = 0.0f; 83 + for (UInt32 i = 0; i < frames; i++) { 84 + float l = a->scratch[i * 2 + 0]; 85 + float r = a->scratch[i * 2 + 1]; 86 + L[i] = l; R[i] = r; 87 + float al = l < 0 ? -l : l; if (al > peak) peak = al; 88 + float ar = r < 0 ? -r : r; if (ar > peak) peak = ar; 89 + } 90 + if (peak > a->peak_out) a->peak_out = peak; 91 + a->samples_out += (uint64_t)frames; 92 + 93 + if (a->trigger_ns && !a->emit_ns && peak > a->emit_threshold) { 94 + a->emit_ns = now_ns(); 95 + } 96 + return noErr; 97 + } 98 + 99 + // Request a specific hardware buffer size on the default output device. 100 + // CoreAudio clamps to [min, max] reported by the driver; actual negotiated 101 + // value is queried back for logging. Returns the accepted size, or 0. 102 + static UInt32 set_hw_buffer(UInt32 target) { 103 + AudioObjectPropertyAddress addr = { 104 + kAudioHardwarePropertyDefaultOutputDevice, 105 + kAudioObjectPropertyScopeGlobal, 106 + kAudioObjectPropertyElementMain, 107 + }; 108 + AudioDeviceID dev = 0; 109 + UInt32 sz = sizeof(dev); 110 + if (AudioObjectGetPropertyData(kAudioObjectSystemObject, &addr, 0, NULL, &sz, &dev) != noErr) 111 + return 0; 112 + 113 + AudioValueRange range = {0}; 114 + addr.mSelector = kAudioDevicePropertyBufferFrameSizeRange; 115 + addr.mScope = kAudioObjectPropertyScopeOutput; 116 + sz = sizeof(range); 117 + if (AudioObjectGetPropertyData(dev, &addr, 0, NULL, &sz, &range) == noErr) { 118 + if (target < (UInt32)range.mMinimum) target = (UInt32)range.mMinimum; 119 + if (target > (UInt32)range.mMaximum) target = (UInt32)range.mMaximum; 120 + } 121 + 122 + addr.mSelector = kAudioDevicePropertyBufferFrameSize; 123 + sz = sizeof(target); 124 + if (AudioObjectSetPropertyData(dev, &addr, 0, NULL, sz, &target) != noErr) return 0; 125 + 126 + UInt32 got = 0; sz = sizeof(got); 127 + AudioObjectGetPropertyData(dev, &addr, 0, NULL, &sz, &got); 128 + return got; 129 + } 130 + 131 + Audio *audio_init(void) { 132 + Audio *a = (Audio *)calloc(1, sizeof(Audio)); 133 + if (!a) return NULL; 134 + pthread_mutex_init(&a->lock, NULL); 135 + a->next_id = 0; 136 + synth_core_init(&a->synth, a->voices, AUDIO_MAX_VOICES, 137 + &a->lock, &a->next_id, (double)DRIVER_SAMPLE_RATE); 138 + 139 + // Set hardware buffer size BEFORE wiring the AU — CoreAudio applies it 140 + // to the shared device, so our AU inherits the tighter schedule. 141 + const char *buf_env = getenv("AC_AUDIO_BUFFER"); 142 + UInt32 target = buf_env ? (UInt32)atoi(buf_env) : 32; 143 + UInt32 got = set_hw_buffer(target); 144 + 145 + AudioComponentDescription desc = { 146 + .componentType = kAudioUnitType_Output, 147 + .componentSubType = kAudioUnitSubType_DefaultOutput, 148 + .componentManufacturer = kAudioUnitManufacturer_Apple, 149 + }; 150 + AudioComponent comp = AudioComponentFindNext(NULL, &desc); 151 + if (!comp) { fprintf(stderr, "[audio] no default output component\n"); pthread_mutex_destroy(&a->lock); free(a); return NULL; } 152 + OSStatus s = AudioComponentInstanceNew(comp, &a->au); 153 + if (s != noErr) { fprintf(stderr, "[audio] AudioComponentInstanceNew: %d\n", (int)s); pthread_mutex_destroy(&a->lock); free(a); return NULL; } 154 + 155 + // Non-interleaved F32 stereo @ 48 kHz — CoreAudio's native path, no 156 + // conversion overhead. 157 + AudioStreamBasicDescription fmt = { 158 + .mSampleRate = DRIVER_SAMPLE_RATE, 159 + .mFormatID = kAudioFormatLinearPCM, 160 + .mFormatFlags = kAudioFormatFlagIsFloat 161 + | kAudioFormatFlagIsPacked 162 + | kAudioFormatFlagIsNonInterleaved, 163 + .mBitsPerChannel = 32, 164 + .mChannelsPerFrame = 2, 165 + .mFramesPerPacket = 1, 166 + .mBytesPerFrame = 4, 167 + .mBytesPerPacket = 4, 168 + }; 169 + s = AudioUnitSetProperty(a->au, kAudioUnitProperty_StreamFormat, 170 + kAudioUnitScope_Input, 0, &fmt, sizeof(fmt)); 171 + if (s != noErr) { fprintf(stderr, "[audio] SetProperty StreamFormat: %d\n", (int)s); goto fail; } 172 + 173 + AURenderCallbackStruct cb = { .inputProc = render_cb, .inputProcRefCon = a }; 174 + s = AudioUnitSetProperty(a->au, kAudioUnitProperty_SetRenderCallback, 175 + kAudioUnitScope_Input, 0, &cb, sizeof(cb)); 176 + if (s != noErr) { fprintf(stderr, "[audio] SetProperty RenderCallback: %d\n", (int)s); goto fail; } 177 + 178 + s = AudioUnitInitialize(a->au); 179 + if (s != noErr) { fprintf(stderr, "[audio] AudioUnitInitialize: %d\n", (int)s); goto fail; } 180 + s = AudioOutputUnitStart(a->au); 181 + if (s != noErr) { fprintf(stderr, "[audio] AudioOutputUnitStart: %d\n", (int)s); AudioUnitUninitialize(a->au); goto fail; } 182 + 183 + a->actual_frames = (int)got; 184 + double latency_ms = got > 0 ? (1000.0 * (double)got / (double)DRIVER_SAMPLE_RATE) : 0.0; 185 + fprintf(stderr, "[audio] CoreAudio AU @ %d Hz, 2 ch, buf=%u frames (~%.2f ms device latency)\n", 186 + DRIVER_SAMPLE_RATE, (unsigned)got, latency_ms); 187 + return a; 188 + 189 + fail: 190 + AudioComponentInstanceDispose(a->au); 191 + pthread_mutex_destroy(&a->lock); 192 + free(a); 193 + return NULL; 194 + } 195 + 196 + void audio_destroy(Audio *a) { 197 + if (!a) return; 198 + fprintf(stderr, "[audio] stop: %llu samples emitted, peak=%.3f\n", 199 + (unsigned long long)a->samples_out, a->peak_out); 200 + if (a->au) { 201 + AudioOutputUnitStop(a->au); 202 + AudioUnitUninitialize(a->au); 203 + AudioComponentInstanceDispose(a->au); 204 + } 205 + free(a->scratch); 206 + pthread_mutex_destroy(&a->lock); 207 + free(a); 208 + } 209 + 210 + // ── Pass-throughs to synth_core ──────────────────────────────────────────── 211 + 212 + uint64_t audio_synth(Audio *a, WaveType w, double freq, double dur, double vol, 213 + double att, double dec, double pan) { 214 + return a ? synth_synth(&a->synth, w, freq, dur, vol, att, dec, pan) : 0; 215 + } 216 + uint64_t audio_synth_gun(Audio *a, GunPreset preset, double duration, 217 + double volume, double attack, double decay, 218 + double pan, double pressure_scale, int force_model) { 219 + return a ? synth_synth_gun(&a->synth, preset, duration, volume, attack, 220 + decay, pan, pressure_scale, force_model) : 0; 221 + } 222 + void audio_kill(Audio *a, uint64_t id, double fade) { if (a) synth_kill(&a->synth, id, fade); } 223 + void audio_update(Audio *a, uint64_t id, double freq, double vol, double pan) { 224 + if (a) synth_update(&a->synth, id, freq, vol, pan); 225 + } 226 + void audio_gun_set_param(Audio *a, uint64_t id, const char *key, double value) { 227 + if (a) synth_gun_set_param(&a->synth, id, key, value); 228 + } 229 + WaveType audio_parse_wave(const char *s) { return synth_parse_wave(s); } 230 + 231 + void audio_arm_latency(Audio *a, float threshold) { 232 + if (!a) return; 233 + a->emit_ns = 0; 234 + a->emit_threshold = threshold > 0.0f ? threshold : 0.02f; 235 + a->trigger_ns = now_ns(); 236 + } 237 + uint64_t audio_latency_ns(Audio *a) { 238 + if (!a || !a->trigger_ns || !a->emit_ns) return 0; 239 + return a->emit_ns - a->trigger_ns; 240 + }
+172
fedac/native/macos/audio_sdl3.c
··· 1 + // audio.c — SDL3 audio driver for the macOS host. 2 + // Thin shell around fedac/native/src/synth_core: owns the SDL3 audio stream 3 + // and pulls stereo float frames out of the shared synth engine. 4 + 5 + #include "audio.h" 6 + #include "synth_core.h" 7 + 8 + #include <SDL3/SDL.h> 9 + #include <pthread.h> 10 + #include <stdlib.h> 11 + #include <string.h> 12 + #include <math.h> 13 + #include <stdio.h> 14 + 15 + // Lower than the Linux 192 kHz target — 48 kHz keeps CPU/scheduling headroom 16 + // on the laptop, and the DSP models behave cleanly at 48 kHz as well. 17 + #define DRIVER_SAMPLE_RATE 48000 18 + #define DRIVER_CHANNELS 2 19 + 20 + struct Audio { 21 + SDL_AudioStream *stream; 22 + SynthCore synth; 23 + pthread_mutex_t lock; 24 + uint64_t next_id; 25 + ACVoice voices[AUDIO_MAX_VOICES]; 26 + // Diagnostics for headless tests. Peak of any sample emitted since init, 27 + // total sample count. Useful to detect silence regressions in CI runs. 28 + volatile float peak_out; 29 + volatile uint64_t samples_out; 30 + // Keypress→sound latency timestamps (SDL_GetTicksNS, monotonic). 31 + volatile uint64_t trigger_ns; // armed by audio_arm_latency() 32 + volatile uint64_t emit_ns; // first callback after arm with |sample| > threshold 33 + volatile float emit_threshold; 34 + }; 35 + 36 + static void SDLCALL audio_callback(void *userdata, SDL_AudioStream *stream, 37 + int additional, int total) { 38 + (void)total; 39 + Audio *a = (Audio *)userdata; 40 + if (additional <= 0) return; 41 + int frames = additional / (int)(sizeof(float) * DRIVER_CHANNELS); 42 + if (frames <= 0) return; 43 + 44 + // Scratch stereo buffer. Stack path covers the common case; fallback to 45 + // heap for larger pulls (some audio drivers ask for big blocks up front). 46 + float stack_buf[1024 * DRIVER_CHANNELS]; 47 + float *buf = stack_buf; 48 + float *heap = NULL; 49 + size_t need = (size_t)frames * DRIVER_CHANNELS; 50 + if (need > sizeof(stack_buf) / sizeof(float)) { 51 + heap = (float *)malloc(need * sizeof(float)); 52 + if (!heap) return; 53 + buf = heap; 54 + } 55 + memset(buf, 0, need * sizeof(float)); 56 + 57 + synth_render(&a->synth, buf, frames); 58 + 59 + // Track peak + sample count for test observability. 60 + float peak = 0.0f; 61 + for (size_t i = 0; i < need; i++) { 62 + float v = buf[i]; if (v < 0) v = -v; 63 + if (v > peak) peak = v; 64 + } 65 + if (peak > a->peak_out) a->peak_out = peak; 66 + a->samples_out += (uint64_t)frames; 67 + 68 + // Latency measurement: if armed and this buffer is loud enough, stamp it. 69 + if (a->trigger_ns && !a->emit_ns && peak > a->emit_threshold) { 70 + a->emit_ns = SDL_GetTicksNS(); 71 + } 72 + 73 + SDL_PutAudioStreamData(stream, buf, additional); 74 + if (heap) free(heap); 75 + } 76 + 77 + Audio *audio_init(void) { 78 + Audio *a = (Audio *)calloc(1, sizeof(Audio)); 79 + if (!a) return NULL; 80 + pthread_mutex_init(&a->lock, NULL); 81 + a->next_id = 0; 82 + synth_core_init(&a->synth, a->voices, AUDIO_MAX_VOICES, 83 + &a->lock, &a->next_id, (double)DRIVER_SAMPLE_RATE); 84 + 85 + // Request a tiny audio buffer so keystroke → sound latency stays under 86 + // one paint frame. AC_AUDIO_BUFFER env var overrides; 64 frames @ 48k 87 + // is ~1.3 ms. Empirically on M-series Macs the CoreAudio device adds 88 + // another ~4–5 ms of its own pipeline, so end-to-end median lands near 89 + // 6 ms. Smaller buffers don't lower that floor, but they tighten the 90 + // jitter ceiling (see AC_LATENCY_TEST for measurements). Must be set 91 + // before opening the device. 92 + const char *buf_env = getenv("AC_AUDIO_BUFFER"); 93 + SDL_SetHint(SDL_HINT_AUDIO_DEVICE_SAMPLE_FRAMES, buf_env ? buf_env : "64"); 94 + 95 + if (!SDL_InitSubSystem(SDL_INIT_AUDIO)) { 96 + fprintf(stderr, "[audio] SDL_InitSubSystem: %s\n", SDL_GetError()); 97 + pthread_mutex_destroy(&a->lock); 98 + free(a); 99 + return NULL; 100 + } 101 + SDL_AudioSpec spec = { SDL_AUDIO_F32, DRIVER_CHANNELS, DRIVER_SAMPLE_RATE }; 102 + a->stream = SDL_OpenAudioDeviceStream(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, 103 + &spec, audio_callback, a); 104 + if (!a->stream) { 105 + fprintf(stderr, "[audio] SDL_OpenAudioDeviceStream: %s\n", SDL_GetError()); 106 + pthread_mutex_destroy(&a->lock); 107 + free(a); 108 + return NULL; 109 + } 110 + SDL_ResumeAudioStreamDevice(a->stream); 111 + 112 + // Query what the device actually negotiated so we can log real latency. 113 + SDL_AudioDeviceID dev = SDL_GetAudioStreamDevice(a->stream); 114 + int frames_per_buf = 0; 115 + SDL_AudioSpec got_spec = {0}; 116 + SDL_GetAudioDeviceFormat(dev, &got_spec, &frames_per_buf); 117 + double latency_ms = (got_spec.freq > 0 && frames_per_buf > 0) 118 + ? (1000.0 * (double)frames_per_buf / (double)got_spec.freq) 119 + : 0.0; 120 + fprintf(stderr, "[audio] ready @ %d Hz, %d ch, buf=%d frames (~%.1f ms device latency)\n", 121 + got_spec.freq ? got_spec.freq : DRIVER_SAMPLE_RATE, 122 + got_spec.channels ? got_spec.channels : DRIVER_CHANNELS, 123 + frames_per_buf, latency_ms); 124 + return a; 125 + } 126 + 127 + void audio_destroy(Audio *a) { 128 + if (!a) return; 129 + fprintf(stderr, "[audio] stop: %llu samples emitted, peak=%.3f\n", 130 + (unsigned long long)a->samples_out, a->peak_out); 131 + if (a->stream) SDL_DestroyAudioStream(a->stream); 132 + pthread_mutex_destroy(&a->lock); 133 + free(a); 134 + } 135 + 136 + uint64_t audio_synth(Audio *a, WaveType w, double freq, double dur, double vol, 137 + double att, double dec, double pan) { 138 + return a ? synth_synth(&a->synth, w, freq, dur, vol, att, dec, pan) : 0; 139 + } 140 + 141 + uint64_t audio_synth_gun(Audio *a, GunPreset preset, double duration, 142 + double volume, double attack, double decay, 143 + double pan, double pressure_scale, int force_model) { 144 + return a ? synth_synth_gun(&a->synth, preset, duration, volume, attack, 145 + decay, pan, pressure_scale, force_model) : 0; 146 + } 147 + 148 + void audio_kill(Audio *a, uint64_t id, double fade) { 149 + if (a) synth_kill(&a->synth, id, fade); 150 + } 151 + 152 + void audio_update(Audio *a, uint64_t id, double freq, double vol, double pan) { 153 + if (a) synth_update(&a->synth, id, freq, vol, pan); 154 + } 155 + 156 + void audio_gun_set_param(Audio *a, uint64_t id, const char *key, double value) { 157 + if (a) synth_gun_set_param(&a->synth, id, key, value); 158 + } 159 + 160 + WaveType audio_parse_wave(const char *s) { return synth_parse_wave(s); } 161 + 162 + void audio_arm_latency(Audio *a, float threshold) { 163 + if (!a) return; 164 + a->emit_ns = 0; 165 + a->emit_threshold = threshold > 0.0f ? threshold : 0.02f; 166 + a->trigger_ns = SDL_GetTicksNS(); 167 + } 168 + 169 + uint64_t audio_latency_ns(Audio *a) { 170 + if (!a || !a->trigger_ns || !a->emit_ns) return 0; 171 + return a->emit_ns - a->trigger_ns; 172 + }
+621
fedac/native/macos/main.c
··· 1 + // ac-native macOS host — stage 2 2 + // SDL3 window + QuickJS-hosted AC piece. The piece module owns drawing; this 3 + // file owns the SDL3 loop, texture upload, and event translation. 4 + 5 + #include <SDL3/SDL.h> 6 + #include <stdio.h> 7 + #include <stdlib.h> 8 + #include <stdint.h> 9 + #include <string.h> 10 + #include <strings.h> 11 + #include <unistd.h> 12 + #include <libgen.h> 13 + #include <mach-o/dyld.h> 14 + #include <Carbon/Carbon.h> 15 + 16 + #include "piece.h" 17 + #include "audio.h" 18 + 19 + // Initial window size in logical points. The framebuffer is win / DENSITY, 20 + // so 640×480 @ d=2 yields a 320×240 canvas — a classic retro resolution 21 + // rendered chunky 2× on-screen (and 4× physical on retina thanks to 22 + // HIGH_PIXEL_DENSITY + nearest-neighbor). 23 + #define INITIAL_WIN_W 640 24 + #define INITIAL_WIN_H 480 25 + #define DEFAULT_DENSITY 2 26 + 27 + // Shared state the event watch callback needs. SDL calls the watch on the 28 + // same thread as SDL_PollEvent, during the OS resize modal run loop, so 29 + // synchronous access without a lock is safe. 30 + typedef struct { 31 + SDL_Window *win; 32 + SDL_Renderer *ren; 33 + SDL_Texture **tex; // by-ref so we can recreate 34 + PieceFB *fb; 35 + PieceCtx *pc; 36 + int density; 37 + } RenderCtx; 38 + 39 + // Reallocate FB + texture + logical presentation when the window size changes. 40 + // Returns true if dimensions actually changed. 41 + static int maybe_reframe(RenderCtx *c) { 42 + int nwp = 0, nhp = 0; 43 + SDL_GetWindowSize(c->win, &nwp, &nhp); 44 + int nw = nwp / c->density; if (nw < 64) nw = 64; 45 + int nh = nhp / c->density; if (nh < 64) nh = 64; 46 + if (nw == c->fb->width && nh == c->fb->height) return 0; 47 + uint32_t *np = calloc((size_t)nw * nh, sizeof(uint32_t)); 48 + if (!np) return 0; 49 + free(c->fb->pixels); 50 + c->fb->pixels = np; 51 + c->fb->width = nw; 52 + c->fb->height = nh; 53 + c->fb->stride = nw; 54 + SDL_DestroyTexture(*c->tex); 55 + *c->tex = SDL_CreateTexture(c->ren, SDL_PIXELFORMAT_ARGB8888, 56 + SDL_TEXTUREACCESS_STREAMING, nw, nh); 57 + SDL_SetTextureScaleMode(*c->tex, SDL_SCALEMODE_NEAREST); 58 + SDL_SetRenderLogicalPresentation(c->ren, nw, nh, 59 + SDL_LOGICAL_PRESENTATION_STRETCH); 60 + piece_reframe(c->pc, nw, nh); 61 + return 1; 62 + } 63 + 64 + // Single frame: ask the piece to paint, upload, present. 65 + static void render_frame(RenderCtx *c) { 66 + piece_paint(c->pc); 67 + SDL_UpdateTexture(*c->tex, NULL, c->fb->pixels, 68 + c->fb->stride * (int)sizeof(uint32_t)); 69 + SDL_RenderClear(c->ren); 70 + SDL_RenderTexture(c->ren, *c->tex, NULL, NULL); 71 + SDL_RenderPresent(c->ren); 72 + } 73 + 74 + // SDL_AddEventWatch callback: fires synchronously from inside the OS resize 75 + // modal run loop on macOS. Without this, the main event loop is frozen 76 + // during drag and the renderer keeps stretching the stale FB to the new 77 + // window size. Handling resize + paint + present here keeps pixels honest. 78 + static bool SDLCALL resize_watch(void *userdata, SDL_Event *ev) { 79 + if (ev->type == SDL_EVENT_WINDOW_RESIZED || 80 + ev->type == SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED || 81 + ev->type == SDL_EVENT_WINDOW_EXPOSED) { 82 + RenderCtx *c = (RenderCtx *)userdata; 83 + maybe_reframe(c); 84 + render_frame(c); 85 + } 86 + return true; 87 + } 88 + 89 + // Translate an SDL_Keycode into the AC key-name used in event types 90 + // ("keyboard:down:<name>"). Covers what hello.mjs and notepat need; extend 91 + // as more pieces are tested. 92 + static void sdl_key_name(SDL_Keycode k, char *out, size_t n) { 93 + const char *s = NULL; 94 + switch (k) { 95 + case SDLK_LEFT: s = "arrowleft"; break; 96 + case SDLK_RIGHT: s = "arrowright"; break; 97 + case SDLK_UP: s = "arrowup"; break; 98 + case SDLK_DOWN: s = "arrowdown"; break; 99 + case SDLK_SPACE: s = "space"; break; 100 + case SDLK_RETURN: s = "enter"; break; 101 + case SDLK_ESCAPE: s = "escape"; break; 102 + case SDLK_BACKSPACE: s = "backspace"; break; 103 + case SDLK_TAB: s = "tab"; break; 104 + default: break; 105 + } 106 + if (s) { snprintf(out, n, "%s", s); return; } 107 + // Printable ASCII: lowercase name, matching AC's pattern. 108 + if (k >= 32 && k < 127) { 109 + char c = (char)k; 110 + if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a'); 111 + snprintf(out, n, "%c", c); 112 + return; 113 + } 114 + // Fall back to SDL's own key name for anything unmapped. 115 + const char *sdl_name = SDL_GetKeyName(k); 116 + if (sdl_name && *sdl_name) snprintf(out, n, "%s", sdl_name); 117 + else snprintf(out, n, "key%u", (unsigned)k); 118 + } 119 + 120 + // If the executable lives inside an .app bundle (path ends with 121 + // Contents/MacOS/<name>), set AC_LIB_PATH and default the piece to the 122 + // bundled Resources/piece.mjs. Returns the piece path to use (points 123 + // into `piece_buf` if a bundle match was found, otherwise NULL). 124 + static const char *detect_bundle(char *piece_buf, size_t piece_sz, 125 + char *lib_buf, size_t lib_sz) { 126 + char exe[1024]; 127 + uint32_t n = (uint32_t)sizeof(exe); 128 + if (_NSGetExecutablePath(exe, &n) != 0) return NULL; 129 + // Resolve symlinks / `./` segments so dirname lands on the real bundle. 130 + char resolved[1024]; 131 + if (!realpath(exe, resolved)) snprintf(resolved, sizeof(resolved), "%s", exe); 132 + char *dir = dirname(resolved); 133 + // Expect ".../Contents/MacOS/<exec>". If so, Resources/ sits next to it. 134 + if (strstr(dir, "/Contents/MacOS")) { 135 + snprintf(piece_buf, piece_sz, "%s/../Resources/piece.mjs", dir); 136 + snprintf(lib_buf, lib_sz, "%s/../Resources/lib", dir); 137 + if (access(piece_buf, R_OK) == 0) { 138 + setenv("AC_LIB_PATH", lib_buf, 1); 139 + return piece_buf; 140 + } 141 + } 142 + return NULL; 143 + } 144 + 145 + // ── Chromeless window helpers ─────────────────────────────────────────────── 146 + 147 + // Cmd-drag moves the borderless window (default + overlay both run chromeless 148 + // so the piece draws its own titlebar). Hit test runs every mouse move to 149 + // decide whether the click belongs to the app or the window manager. 150 + static SDL_HitTestResult SDLCALL hit_test_cmd_drag(SDL_Window *win, 151 + const SDL_Point *pt, 152 + void *data) { 153 + (void)win; (void)pt; (void)data; 154 + SDL_Keymod mod = SDL_GetModState(); 155 + if (mod & SDL_KMOD_GUI) return SDL_HITTEST_DRAGGABLE; 156 + return SDL_HITTEST_NORMAL; 157 + } 158 + 159 + // Build a small RGBA surface for the tray icon. 22×22 fits the macOS menu 160 + // bar comfortably. Pattern mirrors the app icon — yellow "N" on teal. 161 + static SDL_Surface *make_tray_icon_surface(void) { 162 + const int W = 22, H = 22; 163 + SDL_Surface *s = SDL_CreateSurface(W, H, SDL_PIXELFORMAT_RGBA32); 164 + if (!s) return NULL; 165 + uint32_t *px = (uint32_t *)s->pixels; 166 + const uint32_t BG = (255u << 24) | (110u << 16) | (90u << 8) | 45u; // R G B A... careful 167 + // RGBA32 ordering depends on platform — use SDL's packer. 168 + SDL_PixelFormat fmt = s->format; 169 + const SDL_PixelFormatDetails *d = SDL_GetPixelFormatDetails(fmt); 170 + uint32_t bg = SDL_MapRGBA(d, NULL, 45, 90, 110, 255); 171 + uint32_t fg = SDL_MapRGBA(d, NULL, 255, 210, 90, 255); 172 + uint32_t tp = SDL_MapRGBA(d, NULL, 0, 0, 0, 0); 173 + (void)BG; 174 + for (int y = 0; y < H; y++) { 175 + for (int x = 0; x < W; x++) { 176 + int idx = y * W + x; 177 + // Rounded corner mask 178 + int in_corner = 0; 179 + int cr = 4; 180 + if ((x < cr && y < cr)) { int dx = cr-x-1, dy = cr-y-1; if (dx*dx+dy*dy > cr*cr) in_corner = 1; } 181 + if ((x >= W-cr && y < cr)) { int dx = x-(W-cr), dy = cr-y-1; if (dx*dx+dy*dy >= cr*cr) in_corner = 1; } 182 + if ((x < cr && y >= H-cr)) { int dx = cr-x-1, dy = y-(H-cr); if (dx*dx+dy*dy >= cr*cr) in_corner = 1; } 183 + if ((x >= W-cr && y >= H-cr)) { int dx = x-(W-cr), dy = y-(H-cr); if (dx*dx+dy*dy >= cr*cr) in_corner = 1; } 184 + if (in_corner) { px[idx] = tp; continue; } 185 + // Simple "N" — bars at cols 4 and 15, diagonal between. 186 + int is_n = 0; 187 + if ((x >= 4 && x <= 6 && y >= 4 && y <= 17) || 188 + (x >= 15 && x <= 17 && y >= 4 && y <= 17)) is_n = 1; 189 + // diagonal: from (4,4)→(17,17) with width 2 190 + if (!is_n && y >= 4 && y <= 17) { 191 + int target = 4 + (y - 4) * 13 / 13; 192 + if (x >= target + 2 && x <= target + 4) is_n = 1; 193 + } 194 + px[idx] = is_n ? fg : bg; 195 + } 196 + } 197 + return s; 198 + } 199 + 200 + // Flags the tray / hotkey callbacks write; the main loop polls them. 201 + static volatile int g_toggle_visible = 0; 202 + static volatile int g_quit_requested = 0; 203 + 204 + static void SDLCALL tray_show_hide(void *ud, SDL_TrayEntry *entry) { 205 + (void)ud; (void)entry; 206 + g_toggle_visible = 1; 207 + } 208 + static void SDLCALL tray_quit(void *ud, SDL_TrayEntry *entry) { 209 + (void)ud; (void)entry; 210 + g_quit_requested = 1; 211 + } 212 + 213 + static OSStatus hotkey_cb(EventHandlerCallRef href, EventRef ev, void *ud) { 214 + (void)href; (void)ev; (void)ud; 215 + g_toggle_visible = 1; 216 + return noErr; 217 + } 218 + 219 + // Register a system-wide hotkey via Carbon. Doesn't need accessibility 220 + // permission for this path — the app just has to be a foreground bundle. 221 + // keyCode is the Carbon virtual key; modifiers are the Carbon flags. 222 + static EventHotKeyRef g_hotkey = NULL; 223 + static EventHandlerRef g_hotkey_handler = NULL; 224 + static int install_global_hotkey(void) { 225 + EventTypeSpec spec = { kEventClassKeyboard, kEventHotKeyPressed }; 226 + InstallEventHandler(GetEventDispatcherTarget(), (EventHandlerUPP)hotkey_cb, 227 + 1, &spec, NULL, &g_hotkey_handler); 228 + EventHotKeyID id = { .signature = 'ntpt', .id = 1 }; 229 + // kVK_ANSI_N = 0x2D. Modifiers: cmdKey | optionKey | controlKey. 230 + OSStatus s = RegisterEventHotKey(0x2D, 231 + cmdKey | optionKey | controlKey, 232 + id, GetEventDispatcherTarget(), 0, &g_hotkey); 233 + return s == noErr ? 0 : -1; 234 + } 235 + static void uninstall_global_hotkey(void) { 236 + if (g_hotkey) UnregisterEventHotKey(g_hotkey); 237 + if (g_hotkey_handler) RemoveEventHandler(g_hotkey_handler); 238 + g_hotkey = NULL; g_hotkey_handler = NULL; 239 + } 240 + 241 + int main(int argc, char **argv) { 242 + // --test-tone: exercise the audio engine only; no window, no piece. 243 + // Plays a 440 Hz sine for ~1s and prints the peak output sample. Useful 244 + // for verifying audio works in isolation (CI / headless regression). 245 + if (argc > 1 && strcmp(argv[1], "--test-tone") == 0) { 246 + if (!SDL_Init(SDL_INIT_AUDIO)) { 247 + fprintf(stderr, "SDL_Init audio: %s\n", SDL_GetError()); 248 + return 1; 249 + } 250 + Audio *a = audio_init(); 251 + if (!a) { SDL_Quit(); return 1; } 252 + audio_synth(a, WAVE_SINE, 440.0, 1.0, 0.3, 0.01, 0.1, 0.0); 253 + SDL_Delay(1200); 254 + audio_destroy(a); 255 + SDL_Quit(); 256 + return 0; 257 + } 258 + 259 + char bundle_piece[1200], bundle_lib[1200]; 260 + const char *piece_path = NULL; 261 + if (argc > 1) { 262 + piece_path = argv[1]; 263 + } else { 264 + piece_path = detect_bundle(bundle_piece, sizeof(bundle_piece), 265 + bundle_lib, sizeof(bundle_lib)); 266 + if (!piece_path) piece_path = "../test-pieces/hello.mjs"; 267 + } 268 + 269 + if (!SDL_Init(SDL_INIT_VIDEO)) { 270 + fprintf(stderr, "SDL_Init failed: %s\n", SDL_GetError()); 271 + return 1; 272 + } 273 + 274 + // Windowed + resizable by default. AC_FULLSCREEN=1 opts in to fullscreen. 275 + // HIGH_PIXEL_DENSITY is required on retina: without it the renderer works 276 + // at logical-point resolution and macOS bilinear-upscales to the physical 277 + // backing store, which defeats our nearest-neighbor texture filter and 278 + // reads as blurry. With it on, nearest-neighbor stays nearest-neighbor 279 + // all the way through to the pixel. 280 + int fullscreen = getenv("AC_FULLSCREEN") != NULL; 281 + // Transparent-HUD mode is opt-in via AC_OVERLAY=1. 282 + int overlay = 0; 283 + const char *ov_env = getenv("AC_OVERLAY"); 284 + if (ov_env) overlay = (atoi(ov_env) != 0); 285 + // Borderless by default — notepat draws its own chrome, so we skip the 286 + // stock macOS titlebar + traffic lights. Cmd+drag still moves the window 287 + // via the hit test (applied to every non-fullscreen window below). 288 + Uint32 win_flags = SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIGH_PIXEL_DENSITY 289 + | SDL_WINDOW_BORDERLESS; 290 + if (fullscreen) win_flags |= SDL_WINDOW_FULLSCREEN; 291 + if (overlay) { 292 + // Transparent + always-on-top on top of the borderless base turns the 293 + // piece into a HUD that floats over other windows while remaining 294 + // Cmd-draggable. 295 + win_flags |= SDL_WINDOW_TRANSPARENT 296 + | SDL_WINDOW_ALWAYS_ON_TOP; 297 + } 298 + 299 + SDL_Window *win = SDL_CreateWindow("Notepat", 300 + INITIAL_WIN_W, INITIAL_WIN_H, 301 + win_flags); 302 + if (!win) { fprintf(stderr, "SDL_CreateWindow: %s\n", SDL_GetError()); SDL_Quit(); return 1; } 303 + if (!fullscreen) SDL_SetWindowHitTest(win, hit_test_cmd_drag, NULL); 304 + 305 + SDL_Renderer *ren = SDL_CreateRenderer(win, NULL); 306 + if (!ren) { fprintf(stderr, "SDL_CreateRenderer: %s\n", SDL_GetError()); SDL_Quit(); return 1; } 307 + // VSync on by default (smooth paint). AC_LATENCY_TEST disables it so the 308 + // event loop polls tight and latency measurements aren't frame-aligned. 309 + int latency_runs = getenv("AC_LATENCY_TEST") ? atoi(getenv("AC_LATENCY_TEST")) : 0; 310 + if (latency_runs < 0) latency_runs = 0; 311 + SDL_SetRenderVSync(ren, latency_runs > 0 ? 0 : 1); 312 + fprintf(stderr, "[macos] renderer: %s (%s)\n", SDL_GetRendererName(ren), 313 + fullscreen ? "fullscreen" : (overlay ? "overlay" : "windowed")); 314 + 315 + // Pixel density: AC_DENSITY overrides. Higher = smaller framebuffer 316 + // (chunkier pixels), lower = more framebuffer resolution. 317 + const char *density_env = getenv("AC_DENSITY"); 318 + int density = density_env ? atoi(density_env) : DEFAULT_DENSITY; 319 + if (density < 1) density = 1; 320 + if (density > 8) density = 8; 321 + 322 + if (fullscreen) SDL_HideCursor(); 323 + 324 + // Compute initial FB size from the window's *logical* (point) size, not 325 + // its pixel size. This matches web AC's CSS-pixel model: density is 326 + // "points per FB pixel", so a 1280×800 logical window at density 2 gives 327 + // a 640×400 FB regardless of retina scale. Retina sharpness still comes 328 + // from HIGH_PIXEL_DENSITY + nearest-neighbor presentation. 329 + int win_w = INITIAL_WIN_W, win_h = INITIAL_WIN_H; 330 + SDL_GetWindowSize(win, &win_w, &win_h); 331 + int fb_w = win_w / density; if (fb_w < 64) fb_w = 64; 332 + int fb_h = win_h / density; if (fb_h < 64) fb_h = 64; 333 + fprintf(stderr, "[macos] initial fb %dx%d (window %dx%d points, density %d)\n", 334 + fb_w, fb_h, win_w, win_h, density); 335 + 336 + // STRETCH presentation fills the window with no letterbox. Nearest- 337 + // neighbor texture filtering + integer density means pixels stay crisp. 338 + SDL_SetRenderLogicalPresentation(ren, fb_w, fb_h, SDL_LOGICAL_PRESENTATION_STRETCH); 339 + 340 + SDL_Texture *tex = SDL_CreateTexture(ren, SDL_PIXELFORMAT_ARGB8888, 341 + SDL_TEXTUREACCESS_STREAMING, fb_w, fb_h); 342 + if (!tex) { fprintf(stderr, "SDL_CreateTexture: %s\n", SDL_GetError()); SDL_Quit(); return 1; } 343 + SDL_SetTextureScaleMode(tex, SDL_SCALEMODE_NEAREST); 344 + if (overlay) { 345 + // Texture alpha blends over the transparent window; renderer clear 346 + // must use alpha=0 so areas the piece wiped stay see-through. 347 + SDL_SetTextureBlendMode(tex, SDL_BLENDMODE_BLEND); 348 + SDL_SetRenderDrawBlendMode(ren, SDL_BLENDMODE_BLEND); 349 + SDL_SetRenderDrawColor(ren, 0, 0, 0, 0); 350 + } 351 + 352 + PieceFB fb = { 353 + .pixels = calloc((size_t)fb_w * fb_h, sizeof(uint32_t)), 354 + .width = fb_w, 355 + .height = fb_h, 356 + .stride = fb_w, 357 + }; 358 + if (!fb.pixels) { fprintf(stderr, "fb alloc failed\n"); return 1; } 359 + 360 + PieceCtx *pc = piece_load(piece_path, &fb); 361 + if (!pc) { 362 + fprintf(stderr, "[macos] failed to load piece: %s\n", piece_path); 363 + free(fb.pixels); 364 + SDL_DestroyTexture(tex); SDL_DestroyRenderer(ren); SDL_DestroyWindow(win); SDL_Quit(); 365 + return 1; 366 + } 367 + // Make sure the piece sees the real initial dimensions (may differ from 368 + // piece_load defaults if the window grew during creation on some WMs). 369 + piece_reframe(pc, fb.width, fb.height); 370 + if (overlay) piece_set_overlay(pc, 1); 371 + piece_boot(pc); 372 + 373 + // Menu-bar tray: Show/Hide and Quit entries. Callbacks flip flags the 374 + // main loop polls. Tray creation may fail on headless runs; we ignore 375 + // the error since it's not essential. 376 + SDL_Tray *tray = NULL; 377 + SDL_Surface *tray_icon = make_tray_icon_surface(); 378 + if (tray_icon) { 379 + tray = SDL_CreateTray(tray_icon, "Notepat"); 380 + SDL_DestroySurface(tray_icon); 381 + if (tray) { 382 + SDL_TrayMenu *menu = SDL_CreateTrayMenu(tray); 383 + SDL_TrayEntry *e_show = SDL_InsertTrayEntryAt(menu, -1, "Show / Hide Notepat", SDL_TRAYENTRY_BUTTON); 384 + SDL_InsertTrayEntryAt(menu, -1, NULL, SDL_TRAYENTRY_BUTTON); // separator 385 + SDL_TrayEntry *e_quit = SDL_InsertTrayEntryAt(menu, -1, "Quit", SDL_TRAYENTRY_BUTTON); 386 + SDL_SetTrayEntryCallback(e_show, tray_show_hide, NULL); 387 + SDL_SetTrayEntryCallback(e_quit, tray_quit, NULL); 388 + } 389 + } 390 + 391 + // Global hotkey: Ctrl+Alt+Cmd+N toggles window visibility system-wide. 392 + if (install_global_hotkey() == 0) { 393 + fprintf(stderr, "[hotkey] Ctrl+Alt+Cmd+N registered\n"); 394 + } else { 395 + fprintf(stderr, "[hotkey] register failed (another app may own the combo)\n"); 396 + } 397 + 398 + RenderCtx rctx = { .win = win, .ren = ren, .tex = &tex, .fb = &fb, 399 + .pc = pc, .density = density }; 400 + SDL_AddEventWatch(resize_watch, &rctx); 401 + 402 + // Latency benchmark: inject `latency_runs` keypresses, measure each one's 403 + // trigger→first-audio-sample delta, print min/median/max. Vsync is off 404 + // so polling is tight; audio buffer size is what you set via AC_AUDIO_BUFFER. 405 + if (latency_runs > 0) { 406 + const char *lkey = getenv("AC_INJECT_KEY"); 407 + if (!lkey) lkey = "c"; 408 + double lats[256]; 409 + int got = 0; 410 + // Let the audio device warm up + piece settle. 411 + Uint64 warm = SDL_GetTicks(); 412 + while (SDL_GetTicks() - warm < 500) { 413 + SDL_Event e; while (SDL_PollEvent(&e)) {} 414 + piece_sim(pc); render_frame(&rctx); 415 + SDL_Delay(5); 416 + } 417 + Audio *au = piece_audio(pc); 418 + for (int i = 0; i < latency_runs && i < 256; i++) { 419 + // Drain events so the injection isn't behind queued ones. 420 + SDL_Event e; while (SDL_PollEvent(&e)) {} 421 + // Arm immediately before synthesizing the press. piece_act runs 422 + // the piece's handler synchronously, which enqueues the voice. 423 + PieceEvent pe = {0}; 424 + snprintf(pe.key, sizeof(pe.key), "%s", lkey); 425 + snprintf(pe.type, sizeof(pe.type), "keyboard:down:%s", lkey); 426 + if (au) audio_arm_latency(au, 0.005f); 427 + piece_act(pc, &pe); 428 + // Busy-poll for the emit stamp, 50 ms cap. 429 + Uint64 until = SDL_GetTicksNS() + 50000000ULL; 430 + while (SDL_GetTicksNS() < until) { 431 + if (au && audio_latency_ns(au)) break; 432 + } 433 + uint64_t ns = au ? audio_latency_ns(au) : 0; 434 + if (ns) { lats[got++] = (double)ns / 1.0e6; } 435 + // Release + settle before next run so the voice finishes. 436 + PieceEvent peup = {0}; 437 + snprintf(peup.key, sizeof(peup.key), "%s", lkey); 438 + snprintf(peup.type, sizeof(peup.type), "keyboard:up:%s", lkey); 439 + piece_act(pc, &peup); 440 + SDL_Delay(120); 441 + } 442 + if (got > 0) { 443 + // Insertion sort — tiny N. 444 + for (int i = 1; i < got; i++) { 445 + double v = lats[i]; int j = i; 446 + while (j > 0 && lats[j-1] > v) { lats[j] = lats[j-1]; j--; } 447 + lats[j] = v; 448 + } 449 + double sum = 0; for (int i = 0; i < got; i++) sum += lats[i]; 450 + fprintf(stderr, "[latency] %d runs, key=\"%s\": " 451 + "min=%.2f median=%.2f mean=%.2f max=%.2f ms\n", 452 + got, lkey, lats[0], lats[got/2], sum / got, lats[got-1]); 453 + // Dump full list for analysis. 454 + fprintf(stderr, "[latency] samples:"); 455 + for (int i = 0; i < got; i++) fprintf(stderr, " %.2f", lats[i]); 456 + fprintf(stderr, "\n"); 457 + } else { 458 + fprintf(stderr, "[latency] no emissions recorded\n"); 459 + } 460 + SDL_RemoveEventWatch(resize_watch, &rctx); 461 + piece_destroy(pc); 462 + free(fb.pixels); 463 + SDL_DestroyTexture(tex); 464 + SDL_DestroyRenderer(ren); 465 + SDL_DestroyWindow(win); 466 + SDL_Quit(); 467 + return 0; 468 + } 469 + 470 + // Optional single-frame dump for headless verification. Set AC_DUMP_FRAME 471 + // to a path; the host renders one paint cycle, writes a raw ARGB .ppm- 472 + // like dump (actually BGRA-PPM with a header), and exits. 473 + const char *dump_path = getenv("AC_DUMP_FRAME"); 474 + if (dump_path) { 475 + piece_paint(pc); 476 + FILE *f = fopen(dump_path, "wb"); 477 + if (f) { 478 + fprintf(f, "P6\n%d %d\n255\n", fb.width, fb.height); 479 + for (int y = 0; y < fb.height; y++) { 480 + for (int x = 0; x < fb.width; x++) { 481 + uint32_t p = fb.pixels[y * fb.stride + x]; 482 + unsigned char rgb[3] = { (unsigned char)(p >> 16), (unsigned char)(p >> 8), (unsigned char)p }; 483 + fwrite(rgb, 1, 3, f); 484 + } 485 + } 486 + fclose(f); 487 + fprintf(stderr, "[macos] dumped frame to %s\n", dump_path); 488 + } 489 + piece_destroy(pc); 490 + free(fb.pixels); 491 + SDL_DestroyTexture(tex); SDL_DestroyRenderer(ren); SDL_DestroyWindow(win); SDL_Quit(); 492 + return 0; 493 + } 494 + 495 + // Optional headless auto-exit for CI/regression (AC_HEADLESS_MS=<n>). 496 + // Runs the normal event loop but breaks after N ms. Useful for audio tests. 497 + const char *headless_env = getenv("AC_HEADLESS_MS"); 498 + int headless_ms = headless_env ? atoi(headless_env) : 0; 499 + // AC_INJECT_KEY=<name>: after 300 ms, synthesize a keyboard:down:<name> 500 + // event so notepat's sound.synth path fires in a headless run. 501 + const char *inject_key = getenv("AC_INJECT_KEY"); 502 + int injected = 0; 503 + Uint64 start_tick = SDL_GetTicks(); 504 + 505 + int running = 1; 506 + int hidden = 0; 507 + while (running) { 508 + if (g_quit_requested) { running = 0; break; } 509 + if (g_toggle_visible) { 510 + g_toggle_visible = 0; 511 + hidden = !hidden; 512 + if (hidden) SDL_HideWindow(win); 513 + else { SDL_ShowWindow(win); SDL_RaiseWindow(win); } 514 + } 515 + if (headless_ms > 0 && (int)(SDL_GetTicks() - start_tick) >= headless_ms) { 516 + running = 0; 517 + break; 518 + } 519 + if (inject_key && !injected && (SDL_GetTicks() - start_tick) >= 300) { 520 + PieceEvent pe = {0}; 521 + snprintf(pe.key, sizeof(pe.key), "%s", inject_key); 522 + snprintf(pe.type, sizeof(pe.type), "keyboard:down:%s", inject_key); 523 + // Arm the latency stopwatch immediately before dispatching so the 524 + // captured trigger time is as close to "user hits key" as we can 525 + // synthesize. Audio callback stamps first non-silent emission. 526 + Audio *au = piece_audio(pc); 527 + // Low threshold catches the onset of the attack ramp rather than 528 + // waiting for full-level sustain — a more honest keypress→sound 529 + // measurement (matches what an ear would perceive as "the note"). 530 + if (au) audio_arm_latency(au, 0.005f); 531 + piece_act(pc, &pe); 532 + fprintf(stderr, "[inject] keyboard:down:%s\n", inject_key); 533 + injected = 1; 534 + } 535 + // Report latency as soon as the callback stamps first emission. 536 + if (injected && inject_key) { 537 + Audio *au = piece_audio(pc); 538 + uint64_t lat_ns = au ? audio_latency_ns(au) : 0; 539 + if (lat_ns) { 540 + fprintf(stderr, "[latency] key \"%s\" -> first audio sample: %.3f ms\n", 541 + inject_key, (double)lat_ns / 1.0e6); 542 + inject_key = NULL; // only report once 543 + } 544 + } 545 + SDL_Event ev; 546 + while (SDL_PollEvent(&ev)) { 547 + // Remap mouse/touch coords from window pixels into the logical 548 + // FB_W × FB_H canvas so pieces see native framebuffer coords 549 + // regardless of fullscreen scale factor or retina backing. 550 + SDL_ConvertEventToRenderCoordinates(ren, &ev); 551 + if (ev.type == SDL_EVENT_QUIT) running = 0; 552 + else if (ev.type == SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED || 553 + ev.type == SDL_EVENT_WINDOW_RESIZED) { 554 + // The watch callback already handled this during the drag; 555 + // this catches the final tick + any resize outside a drag. 556 + maybe_reframe(&rctx); 557 + continue; 558 + } 559 + else if (ev.type == SDL_EVENT_KEY_DOWN) { 560 + if (ev.key.key == SDLK_ESCAPE) { running = 0; continue; } 561 + // Cmd+= / Cmd+- live-adjust pixel density (zoom in/out). 562 + // Cmd+0 resets to 1 (web-AC parity). Absorbed — piece never 563 + // sees these keypresses. 564 + if (ev.key.mod & SDL_KMOD_GUI) { 565 + int ch = 0; 566 + if (ev.key.key == SDLK_EQUALS || ev.key.key == SDLK_PLUS) ch = +1; 567 + else if (ev.key.key == SDLK_MINUS) ch = -1; 568 + else if (ev.key.key == SDLK_0) ch = 100; // reset 569 + if (ch) { 570 + int new_d = (ch == 100) ? 1 : rctx.density + ch; 571 + if (new_d < 1) new_d = 1; 572 + if (new_d > 8) new_d = 8; 573 + if (new_d != rctx.density) { 574 + rctx.density = new_d; 575 + fprintf(stderr, "[density] %d\n", rctx.density); 576 + maybe_reframe(&rctx); 577 + render_frame(&rctx); 578 + } 579 + continue; 580 + } 581 + } 582 + PieceEvent pe = {0}; 583 + sdl_key_name(ev.key.key, pe.key, sizeof(pe.key)); 584 + snprintf(pe.type, sizeof(pe.type), "keyboard:down:%s", pe.key); 585 + piece_act(pc, &pe); 586 + } else if (ev.type == SDL_EVENT_KEY_UP) { 587 + PieceEvent pe = {0}; 588 + sdl_key_name(ev.key.key, pe.key, sizeof(pe.key)); 589 + snprintf(pe.type, sizeof(pe.type), "keyboard:up:%s", pe.key); 590 + piece_act(pc, &pe); 591 + } else if (ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN) { 592 + PieceEvent pe = { .x = (int)ev.button.x, .y = (int)ev.button.y }; 593 + snprintf(pe.type, sizeof(pe.type), "touch"); 594 + piece_act(pc, &pe); 595 + } else if (ev.type == SDL_EVENT_MOUSE_BUTTON_UP) { 596 + PieceEvent pe = { .x = (int)ev.button.x, .y = (int)ev.button.y }; 597 + snprintf(pe.type, sizeof(pe.type), "lift"); 598 + piece_act(pc, &pe); 599 + } else if (ev.type == SDL_EVENT_MOUSE_MOTION && (ev.motion.state & SDL_BUTTON_LMASK)) { 600 + PieceEvent pe = { .x = (int)ev.motion.x, .y = (int)ev.motion.y }; 601 + snprintf(pe.type, sizeof(pe.type), "draw"); 602 + piece_act(pc, &pe); 603 + } 604 + } 605 + 606 + piece_sim(pc); 607 + render_frame(&rctx); 608 + } 609 + 610 + SDL_RemoveEventWatch(resize_watch, &rctx); 611 + uninstall_global_hotkey(); 612 + if (tray) SDL_DestroyTray(tray); 613 + 614 + piece_destroy(pc); 615 + free(fb.pixels); 616 + SDL_DestroyTexture(tex); 617 + SDL_DestroyRenderer(ren); 618 + SDL_DestroyWindow(win); 619 + SDL_Quit(); 620 + return 0; 621 + }
+879
fedac/native/macos/piece.c
··· 1 + // piece.c — QuickJS host + minimal AC bindings for hello.mjs. 2 + // Keeps scope tight: enough API to run hello.mjs end-to-end. The heavier 3 + // set (graph.c primitives, font.c text, audio, net) comes in later stages. 4 + 5 + #include "piece.h" 6 + #include "quickjs.h" 7 + #include "audio.h" 8 + // Vendored from src/ so we don't pull the full graph.h/drm-display.h chain yet. 9 + #include "font-6x10.h" 10 + #include "font-matrix-chunky8.h" 11 + 12 + #include <SDL3/SDL.h> // SDL_GetSystemTheme for __theme.dark wiring 13 + #include <stdio.h> 14 + #include <stdlib.h> 15 + #include <string.h> 16 + #include <math.h> 17 + #include <time.h> 18 + 19 + struct PieceCtx { 20 + JSRuntime *rt; 21 + JSContext *jsctx; 22 + PieceFB *fb; 23 + 24 + // Current ink color (ARGB). hello.mjs leans on ink() both as a setter 25 + // and as a chain root (ink(...).box(...)), so we keep a persistent slot. 26 + uint32_t ink_argb; 27 + 28 + // Cached lifecycle functions (may be JS_UNDEFINED if absent). 29 + JSValue boot_fn, paint_fn, sim_fn, act_fn; 30 + 31 + // Pre-built global arg object passed into paint/act: shares the 32 + // binding functions so destructuring `{ wipe, ink, ... }` resolves. 33 + JSValue api; 34 + 35 + // Current event (for act()). Rebuilt each call. 36 + const PieceEvent *current_event; 37 + 38 + // Library root for resolving ES module imports like "/lib/percussion.mjs". 39 + // Set from AC_LIB_PATH env var at load time. 40 + char lib_path[1024]; 41 + 42 + // SDL3 audio engine — NULL if init failed (piece still runs, silent). 43 + Audio *audio; 44 + 45 + // Overlay mode: wipe() forces alpha=0 so the desktop shows through the 46 + // cleared area; opaque draws on top still render normally. 47 + int overlay_mode; 48 + }; 49 + 50 + // ── Pixel helpers ─────────────────────────────────────────────────────────── 51 + 52 + static inline void put_pixel(PieceFB *fb, int x, int y, uint32_t c) { 53 + if (x < 0 || x >= fb->width || y < 0 || y >= fb->height) return; 54 + fb->pixels[y * fb->stride + x] = c; 55 + } 56 + 57 + static void fill_rect(PieceFB *fb, int x, int y, int w, int h, uint32_t c) { 58 + int x0 = x < 0 ? 0 : x; 59 + int y0 = y < 0 ? 0 : y; 60 + int x1 = x + w > fb->width ? fb->width : x + w; 61 + int y1 = y + h > fb->height ? fb->height : y + h; 62 + for (int yy = y0; yy < y1; yy++) { 63 + uint32_t *row = fb->pixels + yy * fb->stride; 64 + for (int xx = x0; xx < x1; xx++) row[xx] = c; 65 + } 66 + } 67 + 68 + static void stroke_rect(PieceFB *fb, int x, int y, int w, int h, uint32_t c) { 69 + for (int i = 0; i < w; i++) { put_pixel(fb, x + i, y, c); put_pixel(fb, x + i, y + h - 1, c); } 70 + for (int i = 0; i < h; i++) { put_pixel(fb, x, y + i, c); put_pixel(fb, x + w - 1, y + i, c); } 71 + } 72 + 73 + static void draw_line(PieceFB *fb, int x0, int y0, int x1, int y1, uint32_t c) { 74 + // Bresenham. 75 + int dx = abs(x1 - x0), dy = -abs(y1 - y0); 76 + int sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1; 77 + int err = dx + dy; 78 + while (1) { 79 + put_pixel(fb, x0, y0, c); 80 + if (x0 == x1 && y0 == y1) break; 81 + int e2 = 2 * err; 82 + if (e2 >= dy) { err += dy; x0 += sx; } 83 + if (e2 <= dx) { err += dx; y0 += sy; } 84 + } 85 + } 86 + 87 + // Render a single 6x10 glyph at (x,y) scaled by `scale`. Bit 7 is the 88 + // leftmost column within each row byte (see font-6x10.h). 89 + static void draw_glyph_6x10(PieceFB *fb, int ch, int x, int y, int scale, uint32_t c) { 90 + if (ch < 32 || ch > 126) ch = '?'; 91 + const uint8_t *glyph = font_6x10_data[ch - 32]; 92 + for (int row = 0; row < FONT_6X10_H; row++) { 93 + uint8_t bits = glyph[row]; 94 + for (int col = 0; col < FONT_6X10_W; col++) { 95 + if (bits & (0x80 >> col)) { 96 + int px = x + col * scale; 97 + int py = y + row * scale; 98 + if (scale == 1) put_pixel(fb, px, py, c); 99 + else fill_rect(fb, px, py, scale, scale, c); 100 + } 101 + } 102 + } 103 + } 104 + 105 + // MatrixChunky8: BDF-variable-width font (most glyphs ~3px wide, advance ~4). 106 + // Layout math mirrors src/font.c:font_draw_matrix so x-advance and vertical 107 + // alignment match notepat's layout expectations character-for-character. 108 + static void draw_text_matrix(PieceFB *fb, const char *text, int x, int y, 109 + int scale, uint32_t c) { 110 + if (!text || scale < 1) return; 111 + int start_x = x; 112 + for (const char *p = text; *p; p++) { 113 + unsigned char ch = (unsigned char)*p; 114 + if (ch == '\n') { x = start_x; y += matrix_chunky8_ascent * scale; continue; } 115 + if (ch >= 0x80) continue; 116 + if (ch < 32 || ch > 126) ch = '?'; 117 + const BDFGlyph *gl = &matrix_chunky8_glyphs[ch - 32]; 118 + int gy = y + (matrix_chunky8_ascent - gl->yoff - gl->height) * scale; 119 + int gx = x + gl->xoff * scale; 120 + for (int row = 0; row < gl->height && row < MATRIX_CHUNKY8_MAX_H; row++) { 121 + uint8_t bits = gl->rows[row]; 122 + for (int col = 0; col < gl->width; col++) { 123 + if (bits & (0x80 >> col)) { 124 + int px = gx + col * scale; 125 + int py = gy + row * scale; 126 + if (scale == 1) put_pixel(fb, px, py, c); 127 + else fill_rect(fb, px, py, scale, scale, c); 128 + } 129 + } 130 + } 131 + x += gl->dwidth * scale; 132 + } 133 + } 134 + 135 + static void draw_text_6x10(PieceFB *fb, const char *text, int x, int y, int scale, uint32_t c) { 136 + int col = 0; 137 + for (const char *p = text; *p; p++) { 138 + unsigned char ch = (unsigned char)*p; 139 + // Skip UTF-8 continuation bytes; render the lead byte as ? for now. 140 + if (ch >= 0x80) continue; 141 + if (ch == '\n') { col = 0; y += FONT_6X10_H * scale; continue; } 142 + draw_glyph_6x10(fb, ch, x + col * FONT_6X10_W * scale, y, scale, c); 143 + col++; 144 + } 145 + } 146 + 147 + static void draw_circle(PieceFB *fb, int cx, int cy, int r, int filled, uint32_t c) { 148 + if (r <= 0) return; 149 + if (filled) { 150 + for (int y = -r; y <= r; y++) { 151 + int w = (int)sqrtf((float)(r * r - y * y)); 152 + for (int x = -w; x <= w; x++) put_pixel(fb, cx + x, cy + y, c); 153 + } 154 + } else { 155 + // Midpoint circle. 156 + int x = r, y = 0, err = 1 - r; 157 + while (x >= y) { 158 + put_pixel(fb, cx + x, cy + y, c); 159 + put_pixel(fb, cx + y, cy + x, c); 160 + put_pixel(fb, cx - y, cy + x, c); 161 + put_pixel(fb, cx - x, cy + y, c); 162 + put_pixel(fb, cx - x, cy - y, c); 163 + put_pixel(fb, cx - y, cy - x, c); 164 + put_pixel(fb, cx + y, cy - x, c); 165 + put_pixel(fb, cx + x, cy - y, c); 166 + y++; 167 + if (err <= 0) err += 2 * y + 1; 168 + else { x--; err += 2 * (y - x) + 1; } 169 + } 170 + } 171 + } 172 + 173 + // ── JS bindings ───────────────────────────────────────────────────────────── 174 + 175 + static PieceCtx *ctx_from(JSContext *jsctx) { 176 + return (PieceCtx *)JS_GetContextOpaque(jsctx); 177 + } 178 + 179 + static uint32_t pack_rgb(int r, int g, int b, int a) { 180 + if (r < 0) r = 0; if (r > 255) r = 255; 181 + if (g < 0) g = 0; if (g > 255) g = 255; 182 + if (b < 0) b = 0; if (b > 255) b = 255; 183 + if (a < 0) a = 0; if (a > 255) a = 255; 184 + return ((uint32_t)a << 24) | ((uint32_t)r << 16) | ((uint32_t)g << 8) | (uint32_t)b; 185 + } 186 + 187 + // Interpret 0–4 numeric args as (r,g,b[,a]) or (gray) with alpha 255 default. 188 + static uint32_t args_to_argb(JSContext *jsctx, int argc, JSValueConst *argv) { 189 + int vals[4] = {0, 0, 0, 255}; 190 + int n = argc < 4 ? argc : 4; 191 + for (int i = 0; i < n; i++) { 192 + int32_t v = 0; 193 + if (JS_ToInt32(jsctx, &v, argv[i]) == 0) vals[i] = v; 194 + } 195 + if (argc == 1) { vals[1] = vals[0]; vals[2] = vals[0]; } 196 + return pack_rgb(vals[0], vals[1], vals[2], vals[3]); 197 + } 198 + 199 + static JSValue js_wipe(JSContext *jsctx, JSValueConst this_val, int argc, JSValueConst *argv) { 200 + (void)this_val; 201 + PieceCtx *pc = ctx_from(jsctx); 202 + uint32_t c = args_to_argb(jsctx, argc, argv); 203 + // In overlay mode the wipe fill is transparent so the desktop shows 204 + // through — keep RGB for debugging, force alpha to zero. Subsequent 205 + // ink() sets / draws use the fully-opaque color as normal. 206 + uint32_t fill_c = pc->overlay_mode ? (c & 0x00FFFFFF) : c; 207 + fill_rect(pc->fb, 0, 0, pc->fb->width, pc->fb->height, fill_c); 208 + pc->ink_argb = c; 209 + return JS_UNDEFINED; 210 + } 211 + 212 + static JSValue js_ink(JSContext *jsctx, JSValueConst this_val, int argc, JSValueConst *argv) { 213 + (void)this_val; 214 + PieceCtx *pc = ctx_from(jsctx); 215 + pc->ink_argb = args_to_argb(jsctx, argc, argv); 216 + // Return the api object so `ink(...).box(...)` chains work. 217 + return JS_DupValue(jsctx, pc->api); 218 + } 219 + 220 + static JSValue js_box(JSContext *jsctx, JSValueConst this_val, int argc, JSValueConst *argv) { 221 + (void)this_val; 222 + PieceCtx *pc = ctx_from(jsctx); 223 + int32_t x = 0, y = 0, w = 0, h = 0; 224 + if (argc >= 1) JS_ToInt32(jsctx, &x, argv[0]); 225 + if (argc >= 2) JS_ToInt32(jsctx, &y, argv[1]); 226 + if (argc >= 3) JS_ToInt32(jsctx, &w, argv[2]); 227 + if (argc >= 4) JS_ToInt32(jsctx, &h, argv[3]); 228 + int outline = 0; 229 + if (argc >= 5 && JS_IsString(argv[4])) { 230 + const char *s = JS_ToCString(jsctx, argv[4]); 231 + if (s) { if (strcmp(s, "outline") == 0) outline = 1; JS_FreeCString(jsctx, s); } 232 + } 233 + if (outline) stroke_rect(pc->fb, x, y, w, h, pc->ink_argb); 234 + else fill_rect (pc->fb, x, y, w, h, pc->ink_argb); 235 + return JS_UNDEFINED; 236 + } 237 + 238 + static JSValue js_line(JSContext *jsctx, JSValueConst this_val, int argc, JSValueConst *argv) { 239 + (void)this_val; 240 + PieceCtx *pc = ctx_from(jsctx); 241 + int32_t x0 = 0, y0 = 0, x1 = 0, y1 = 0; 242 + if (argc >= 1) JS_ToInt32(jsctx, &x0, argv[0]); 243 + if (argc >= 2) JS_ToInt32(jsctx, &y0, argv[1]); 244 + if (argc >= 3) JS_ToInt32(jsctx, &x1, argv[2]); 245 + if (argc >= 4) JS_ToInt32(jsctx, &y1, argv[3]); 246 + draw_line(pc->fb, x0, y0, x1, y1, pc->ink_argb); 247 + return JS_UNDEFINED; 248 + } 249 + 250 + static JSValue js_circle(JSContext *jsctx, JSValueConst this_val, int argc, JSValueConst *argv) { 251 + (void)this_val; 252 + PieceCtx *pc = ctx_from(jsctx); 253 + int32_t cx = 0, cy = 0, r = 0; 254 + if (argc >= 1) JS_ToInt32(jsctx, &cx, argv[0]); 255 + if (argc >= 2) JS_ToInt32(jsctx, &cy, argv[1]); 256 + if (argc >= 3) JS_ToInt32(jsctx, &r, argv[2]); 257 + int filled = 0; 258 + if (argc >= 4) filled = JS_ToBool(jsctx, argv[3]); 259 + draw_circle(pc->fb, cx, cy, r, filled, pc->ink_argb); 260 + return JS_UNDEFINED; 261 + } 262 + 263 + // write(text, { x, y, size, font }) 264 + // Picks MatrixChunky8 for font: "matrix" (narrow, ~4px advance — matches 265 + // notepat's layout math) and 6x10 for everything else. x/y default to 0; 266 + // size defaults to 1. 267 + static JSValue js_write(JSContext *jsctx, JSValueConst this_val, int argc, JSValueConst *argv) { 268 + (void)this_val; 269 + PieceCtx *pc = ctx_from(jsctx); 270 + if (argc < 1 || !JS_IsString(argv[0])) return JS_UNDEFINED; 271 + const char *text = JS_ToCString(jsctx, argv[0]); 272 + if (!text) return JS_UNDEFINED; 273 + 274 + int32_t x = 0, y = 0, size = 1; 275 + int use_matrix = 0; 276 + if (argc >= 2 && JS_IsObject(argv[1])) { 277 + JSValue vx = JS_GetPropertyStr(jsctx, argv[1], "x"); 278 + JSValue vy = JS_GetPropertyStr(jsctx, argv[1], "y"); 279 + JSValue vs = JS_GetPropertyStr(jsctx, argv[1], "size"); 280 + JSValue vf = JS_GetPropertyStr(jsctx, argv[1], "font"); 281 + if (!JS_IsUndefined(vx)) JS_ToInt32(jsctx, &x, vx); 282 + if (!JS_IsUndefined(vy)) JS_ToInt32(jsctx, &y, vy); 283 + if (!JS_IsUndefined(vs)) JS_ToInt32(jsctx, &size, vs); 284 + if (JS_IsString(vf)) { 285 + const char *fn = JS_ToCString(jsctx, vf); 286 + if (fn) { if (strcmp(fn, "matrix") == 0) use_matrix = 1; JS_FreeCString(jsctx, fn); } 287 + } 288 + JS_FreeValue(jsctx, vx); JS_FreeValue(jsctx, vy); 289 + JS_FreeValue(jsctx, vs); JS_FreeValue(jsctx, vf); 290 + } 291 + if (size < 1) size = 1; 292 + if (use_matrix) draw_text_matrix(pc->fb, text, x, y, size, pc->ink_argb); 293 + else draw_text_6x10 (pc->fb, text, x, y, size, pc->ink_argb); 294 + JS_FreeCString(jsctx, text); 295 + return JS_UNDEFINED; 296 + } 297 + 298 + // performance.now(): fractional milliseconds since an arbitrary epoch. 299 + // Matches Web Performance API semantics closely enough for piece code. 300 + static JSValue js_performance_now(JSContext *jsctx, JSValueConst this_val, int argc, JSValueConst *argv) { 301 + (void)this_val; (void)argc; (void)argv; 302 + struct timespec ts; 303 + clock_gettime(CLOCK_MONOTONIC, &ts); 304 + double ms = (double)ts.tv_sec * 1000.0 + (double)ts.tv_nsec / 1.0e6; 305 + return JS_NewFloat64(jsctx, ms); 306 + } 307 + 308 + // Returns true when macOS is in dark mode. SDL3 queries NSApp's effective 309 + // appearance on our behalf, which tracks the per-app override as well as the 310 + // system-wide setting. Used by __theme.update() so notepat reflects the real 311 + // appearance instead of its fallback LA-clock heuristic. 312 + static JSValue js_theme_dark(JSContext *jsctx, JSValueConst this_val, int argc, JSValueConst *argv) { 313 + (void)this_val; (void)argc; (void)argv; 314 + return JS_NewBool(jsctx, SDL_GetSystemTheme() == SDL_SYSTEM_THEME_DARK); 315 + } 316 + 317 + static JSValue js_console_log(JSContext *jsctx, JSValueConst this_val, int argc, JSValueConst *argv) { 318 + (void)this_val; 319 + for (int i = 0; i < argc; i++) { 320 + const char *s = JS_ToCString(jsctx, argv[i]); 321 + if (!s) continue; 322 + fprintf(stderr, "%s%s", i ? " " : "", s); 323 + JS_FreeCString(jsctx, s); 324 + } 325 + fprintf(stderr, "\n"); 326 + return JS_UNDEFINED; 327 + } 328 + 329 + // ── Sound bindings (Phase B) ──────────────────────────────────────────────── 330 + // sound.synth returns a handle object carrying __voiceId + kill/update methods 331 + // that reach back into the C audio engine via ctx_from(jsctx)->audio. 332 + // The kill/update on `sound` itself take (id, fade) for convenience. 333 + 334 + static double opt_num(JSContext *cx, JSValueConst obj, const char *key, double dflt) { 335 + JSValue v = JS_GetPropertyStr(cx, obj, key); 336 + double r = dflt; 337 + if (!JS_IsUndefined(v) && !JS_IsNull(v)) { 338 + double d = 0.0; 339 + if (JS_ToFloat64(cx, &d, v) == 0) r = d; 340 + } 341 + JS_FreeValue(cx, v); 342 + return r; 343 + } 344 + 345 + static char *opt_str(JSContext *cx, JSValueConst obj, const char *key) { 346 + JSValue v = JS_GetPropertyStr(cx, obj, key); 347 + char *out = NULL; 348 + if (JS_IsString(v)) { 349 + const char *s = JS_ToCString(cx, v); 350 + if (s) { out = strdup(s); JS_FreeCString(cx, s); } 351 + } 352 + JS_FreeValue(cx, v); 353 + return out; 354 + } 355 + 356 + static uint64_t handle_get_id(JSContext *cx, JSValueConst this_val) { 357 + JSValue vid = JS_GetPropertyStr(cx, this_val, "__voiceId"); 358 + int64_t id = 0; 359 + JS_ToInt64(cx, &id, vid); 360 + JS_FreeValue(cx, vid); 361 + return (uint64_t)id; 362 + } 363 + 364 + static JSValue js_handle_kill(JSContext *jsctx, JSValueConst this_val, int argc, JSValueConst *argv) { 365 + PieceCtx *pc = ctx_from(jsctx); 366 + uint64_t id = handle_get_id(jsctx, this_val); 367 + double fade = 0.01; 368 + if (argc >= 1) { double d; if (JS_ToFloat64(jsctx, &d, argv[0]) == 0) fade = d; } 369 + audio_kill(pc->audio, id, fade); 370 + return JS_UNDEFINED; 371 + } 372 + 373 + static JSValue js_handle_update(JSContext *jsctx, JSValueConst this_val, int argc, JSValueConst *argv) { 374 + PieceCtx *pc = ctx_from(jsctx); 375 + uint64_t id = handle_get_id(jsctx, this_val); 376 + double freq = NAN, vol = NAN, pan = NAN; 377 + if (argc >= 1 && JS_IsObject(argv[0])) { 378 + freq = opt_num(jsctx, argv[0], "tone", NAN); 379 + vol = opt_num(jsctx, argv[0], "volume", NAN); 380 + pan = opt_num(jsctx, argv[0], "pan", NAN); 381 + } 382 + audio_update(pc->audio, id, freq, vol, pan); 383 + return JS_UNDEFINED; 384 + } 385 + 386 + static JSValue js_sound_synth(JSContext *jsctx, JSValueConst this_val, int argc, JSValueConst *argv) { 387 + (void)this_val; 388 + PieceCtx *pc = ctx_from(jsctx); 389 + if (argc < 1 || !JS_IsObject(argv[0])) return JS_NULL; 390 + 391 + char *type_s = opt_str(jsctx, argv[0], "type"); 392 + double tone = opt_num(jsctx, argv[0], "tone", 220.0); 393 + double duration = opt_num(jsctx, argv[0], "duration", 0.25); 394 + double volume = opt_num(jsctx, argv[0], "volume", 0.5); 395 + double attack = opt_num(jsctx, argv[0], "attack", 0.01); 396 + double decay = opt_num(jsctx, argv[0], "decay", 0.1); 397 + double pan = opt_num(jsctx, argv[0], "pan", 0.0); 398 + 399 + // Route gun preset names ("gun:pistol", "gun:sniper", ...) through the 400 + // full preset engine; everything else goes to the shared oscillator 401 + // path. Lets notepat opt-in to the richer percussion via a naming 402 + // convention without a new API surface. 403 + uint64_t id = 0; 404 + if (type_s && strncmp(type_s, "gun:", 4) == 0) { 405 + static const struct { const char *name; GunPreset preset; } guns[] = { 406 + {"pistol", GUN_PISTOL}, {"rifle", GUN_RIFLE}, 407 + {"shotgun", GUN_SHOTGUN}, {"smg", GUN_SMG}, 408 + {"suppressed", GUN_SUPPRESSED}, {"lmg", GUN_LMG}, 409 + {"sniper", GUN_SNIPER}, {"grenade", GUN_GRENADE}, 410 + {"rpg", GUN_RPG}, {"reload", GUN_RELOAD}, 411 + {"cock", GUN_COCK}, {"ricochet", GUN_RICOCHET}, 412 + }; 413 + GunPreset preset = GUN_PISTOL; 414 + for (size_t i = 0; i < sizeof(guns)/sizeof(guns[0]); i++) { 415 + if (strcmp(type_s + 4, guns[i].name) == 0) { preset = guns[i].preset; break; } 416 + } 417 + id = audio_synth_gun(pc->audio, preset, duration, volume, attack, decay, 418 + pan, 1.0, -1); 419 + } else { 420 + WaveType wave = audio_parse_wave(type_s); 421 + id = audio_synth(pc->audio, wave, tone, duration, volume, attack, decay, pan); 422 + } 423 + free(type_s); 424 + if (!id) return JS_NULL; 425 + 426 + JSValue handle = JS_NewObject(jsctx); 427 + JS_SetPropertyStr(jsctx, handle, "__voiceId", JS_NewInt64(jsctx, (int64_t)id)); 428 + JS_SetPropertyStr(jsctx, handle, "kill", JS_NewCFunction(jsctx, js_handle_kill, "kill", 1)); 429 + JS_SetPropertyStr(jsctx, handle, "update", JS_NewCFunction(jsctx, js_handle_update, "update", 1)); 430 + return handle; 431 + } 432 + 433 + static JSValue js_sound_kill(JSContext *jsctx, JSValueConst this_val, int argc, JSValueConst *argv) { 434 + (void)this_val; 435 + PieceCtx *pc = ctx_from(jsctx); 436 + // Accept (handle) or (id, fade). If the first arg is an object, treat as handle. 437 + uint64_t id = 0; double fade = 0.01; 438 + if (argc >= 1) { 439 + if (JS_IsObject(argv[0])) id = handle_get_id(jsctx, argv[0]); 440 + else { int64_t v; if (JS_ToInt64(jsctx, &v, argv[0]) == 0) id = (uint64_t)v; } 441 + } 442 + if (argc >= 2) { double d; if (JS_ToFloat64(jsctx, &d, argv[1]) == 0) fade = d; } 443 + audio_kill(pc->audio, id, fade); 444 + return JS_UNDEFINED; 445 + } 446 + 447 + // Event shape: { is: (pattern) => bool, x, y }. 448 + static JSValue js_event_is(JSContext *jsctx, JSValueConst this_val, int argc, JSValueConst *argv) { 449 + (void)this_val; 450 + PieceCtx *pc = ctx_from(jsctx); 451 + if (!pc->current_event || argc < 1 || !JS_IsString(argv[0])) return JS_FALSE; 452 + const char *pat = JS_ToCString(jsctx, argv[0]); 453 + if (!pat) return JS_FALSE; 454 + // Support exact match and prefix match: e.is("touch") matches "touch", 455 + // e.is("keyboard:down") matches any "keyboard:down:*". Matches AC semantics. 456 + const char *t = pc->current_event->type; 457 + int ok = 0; 458 + size_t plen = strlen(pat), tlen = strlen(t); 459 + if (plen == tlen && strcmp(pat, t) == 0) ok = 1; 460 + else if (plen < tlen && strncmp(pat, t, plen) == 0 && t[plen] == ':') ok = 1; 461 + JS_FreeCString(jsctx, pat); 462 + return ok ? JS_TRUE : JS_FALSE; 463 + } 464 + 465 + // ── Setup ──────────────────────────────────────────────────────────────────── 466 + 467 + static JSValue build_screen_obj(JSContext *jsctx, int w, int h) { 468 + JSValue o = JS_NewObject(jsctx); 469 + JS_SetPropertyStr(jsctx, o, "width", JS_NewInt32(jsctx, w)); 470 + JS_SetPropertyStr(jsctx, o, "height", JS_NewInt32(jsctx, h)); 471 + return o; 472 + } 473 + 474 + static char *read_file(const char *path, size_t *out_len); // fwd decl 475 + 476 + // Resolve a module specifier (e.g. "/lib/percussion.mjs") to a filesystem 477 + // path. Leading slash = rooted at the lib path; anything else is treated 478 + // relative to the piece directory (caller responsibility, not handled yet). 479 + static void resolve_module(PieceCtx *pc, const char *name, char *out, size_t n) { 480 + if (name[0] == '/') { 481 + // strip one leading "/lib" if present to avoid doubling. 482 + const char *rest = name; 483 + if (strncmp(name, "/lib/", 5) == 0) rest = name + 4; // keep the "/" 484 + snprintf(out, n, "%s%s", pc->lib_path, rest); 485 + } else { 486 + snprintf(out, n, "%s", name); 487 + } 488 + } 489 + 490 + // QuickJS module loader: read the file, compile as a module, return its 491 + // JSModuleDef*. QuickJS wraps the def in a JSValue for us; we unwrap via 492 + // JS_VALUE_GET_PTR. Signature matches JSModuleLoaderFunc. 493 + static JSModuleDef *piece_module_loader(JSContext *jsctx, const char *module_name, void *opaque) { 494 + PieceCtx *pc = (PieceCtx *)opaque; 495 + char path[1200]; 496 + resolve_module(pc, module_name, path, sizeof(path)); 497 + 498 + size_t src_len = 0; 499 + char *src = read_file(path, &src_len); 500 + if (!src) { 501 + JS_ThrowReferenceError(jsctx, "module not found: %s (resolved to %s)", module_name, path); 502 + return NULL; 503 + } 504 + JSValue compiled = JS_Eval(jsctx, src, src_len, module_name, 505 + JS_EVAL_TYPE_MODULE | JS_EVAL_FLAG_COMPILE_ONLY); 506 + free(src); 507 + if (JS_IsException(compiled)) return NULL; 508 + JSModuleDef *m = JS_VALUE_GET_PTR(compiled); 509 + JS_FreeValue(jsctx, compiled); 510 + return m; 511 + } 512 + 513 + static char *read_file(const char *path, size_t *out_len) { 514 + FILE *f = fopen(path, "rb"); 515 + if (!f) return NULL; 516 + fseek(f, 0, SEEK_END); 517 + long sz = ftell(f); 518 + fseek(f, 0, SEEK_SET); 519 + if (sz < 0) { fclose(f); return NULL; } 520 + char *buf = malloc((size_t)sz + 1); 521 + if (!buf) { fclose(f); return NULL; } 522 + if (fread(buf, 1, (size_t)sz, f) != (size_t)sz) { free(buf); fclose(f); return NULL; } 523 + buf[sz] = 0; 524 + fclose(f); 525 + if (out_len) *out_len = (size_t)sz; 526 + return buf; 527 + } 528 + 529 + PieceCtx *piece_load(const char *path, PieceFB *fb) { 530 + PieceCtx *pc = calloc(1, sizeof(PieceCtx)); 531 + if (!pc) return NULL; 532 + pc->fb = fb; 533 + pc->ink_argb = 0xFFFFFFFF; 534 + pc->audio = audio_init(); // NULL if unavailable; JS stubs keep working 535 + 536 + pc->rt = JS_NewRuntime(); 537 + if (!pc->rt) { free(pc); return NULL; } 538 + pc->jsctx = JS_NewContext(pc->rt); 539 + if (!pc->jsctx) { JS_FreeRuntime(pc->rt); free(pc); return NULL; } 540 + JS_SetContextOpaque(pc->jsctx, pc); 541 + 542 + // Build the api object and install its methods both on itself and on 543 + // globalThis. This lets pieces do `function paint({wipe, ink, ...})` 544 + // (destructure from arg) *and* call bare `box(...)` at module scope. 545 + JSContext *cx = pc->jsctx; 546 + JSValue global = JS_GetGlobalObject(cx); 547 + pc->api = JS_NewObject(cx); 548 + 549 + struct { const char *name; JSCFunction *fn; int argc; } binds[] = { 550 + { "wipe", js_wipe, 0 }, 551 + { "ink", js_ink, 0 }, 552 + { "box", js_box, 0 }, 553 + { "line", js_line, 0 }, 554 + { "circle", js_circle, 0 }, 555 + { "write", js_write, 0 }, 556 + { NULL, NULL, 0 } 557 + }; 558 + for (int i = 0; binds[i].name; i++) { 559 + JSValue f = JS_NewCFunction(cx, binds[i].fn, binds[i].name, binds[i].argc); 560 + JS_SetPropertyStr(cx, pc->api, binds[i].name, JS_DupValue(cx, f)); 561 + JS_SetPropertyStr(cx, global, binds[i].name, f); 562 + } 563 + 564 + // screen as a plain object on the api (hello.mjs reads .width/.height) 565 + JSValue scr = build_screen_obj(cx, fb->width, fb->height); 566 + JS_SetPropertyStr(cx, pc->api, "screen", JS_DupValue(cx, scr)); 567 + JS_SetPropertyStr(cx, global, "screen", scr); 568 + 569 + // Phase A: stubs are JS-level. Full-fat no-ops live on globalThis AND 570 + // on the api object so destructured params (`{ sound }`) resolve too. 571 + // Phase B replaces `sound` with a real audio engine bound in C. 572 + #ifndef AC_BUILD_NAME 573 + #define AC_BUILD_NAME "macos-dev" 574 + #endif 575 + const char *stubs_js = 576 + "(function(api, version){\n" 577 + " const noop = () => undefined;\n" 578 + " const handle = () => ({ update: noop, kill: noop });\n" 579 + " const sound = {\n" 580 + " synth: handle, kill: noop,\n" 581 + " sample: { play: handle, kill: noop, loadData: noop, getData: noop },\n" 582 + " replay: { play: handle, kill: noop, loadData: noop, update: noop },\n" 583 + " deck: { play: noop, pause: noop, seek: noop, setSpeed: noop,\n" 584 + " setVolume: noop, setCrossfader: noop, setMasterVolume: noop,\n" 585 + " load: noop, decks: [] },\n" 586 + " room: { setMix: noop },\n" 587 + " glitch: { setMix: noop },\n" 588 + " fx: { setMix: noop },\n" 589 + " speaker: { getRecentBuffer: () => ({ length: 0, rate: 48000 }),\n" 590 + " systemVolume: 50 },\n" 591 + " microphone: { sampleLength: 0, sampleRate: 48000, device: 'none' },\n" 592 + " speak: noop,\n" 593 + " };\n" 594 + " const wifi = { scan: noop, connect: noop, disconnect: noop,\n" 595 + " networks: [], connected: false, state: 'idle',\n" 596 + " status: 'offline', ip: '', iface: '' };\n" 597 + " const system = { version,\n" 598 + " readFile: () => null, writeFile: noop,\n" 599 + " fetchBinary: noop, mountMusic: noop, mountMusicMounted: false,\n" 600 + " hdmi: null, typec: [], usbMidi: { status: noop },\n" 601 + " ws: null, udp: null, jump: noop };\n" 602 + " const trackpad = { dx: 0, dy: 0 };\n" 603 + " const pressures = [];\n" 604 + " // Host-provided globals the piece expects. __theme.dark tracks\n" 605 + " // macOS's effective appearance; update() re-queries via a C binding\n" 606 + " // installed later so notepat's 5s poll picks up Settings changes.\n" 607 + " globalThis.__theme = {\n" 608 + " dark: false,\n" 609 + " update() { if (globalThis.__theme_dark) this.dark = !!globalThis.__theme_dark(); },\n" 610 + " };\n" 611 + " // Named exports visible both as globals and via the api arg.\n" 612 + " for (const [k, v] of Object.entries({ sound, wifi, system, trackpad, pressures })) {\n" 613 + " globalThis[k] = v;\n" 614 + " api[k] = v;\n" 615 + " }\n" 616 + "})"; 617 + { 618 + JSValue fn = JS_Eval(cx, stubs_js, strlen(stubs_js), "<stubs>", JS_EVAL_TYPE_GLOBAL); 619 + if (JS_IsException(fn)) { 620 + JSValue exc = JS_GetException(cx); 621 + const char *msg = JS_ToCString(cx, exc); 622 + fprintf(stderr, "[piece] stubs compile: %s\n", msg ? msg : "(unknown)"); 623 + if (msg) JS_FreeCString(cx, msg); 624 + JS_FreeValue(cx, exc); 625 + } else { 626 + JSValue args[2] = { JS_DupValue(cx, pc->api), JS_NewString(cx, AC_BUILD_NAME) }; 627 + JSValue r = JS_Call(cx, fn, JS_UNDEFINED, 2, args); 628 + if (JS_IsException(r)) { 629 + JSValue exc = JS_GetException(cx); 630 + const char *msg = JS_ToCString(cx, exc); 631 + fprintf(stderr, "[piece] stubs run: %s\n", msg ? msg : "(unknown)"); 632 + if (msg) JS_FreeCString(cx, msg); 633 + JS_FreeValue(cx, exc); 634 + } 635 + JS_FreeValue(cx, r); 636 + JS_FreeValue(cx, args[0]); 637 + JS_FreeValue(cx, args[1]); 638 + JS_FreeValue(cx, fn); 639 + } 640 + } 641 + 642 + // Phase B: overlay real C-bound synth/kill onto the sound stub. Nested 643 + // namespaces (room/glitch/fx/deck/sample/replay/speak) stay as JS no-ops 644 + // for now — only note playback is wired through. 645 + if (pc->audio) { 646 + JSValue sound_obj = JS_GetPropertyStr(cx, global, "sound"); 647 + if (JS_IsObject(sound_obj)) { 648 + JS_SetPropertyStr(cx, sound_obj, "synth", 649 + JS_NewCFunction(cx, js_sound_synth, "synth", 1)); 650 + JS_SetPropertyStr(cx, sound_obj, "kill", 651 + JS_NewCFunction(cx, js_sound_kill, "kill", 2)); 652 + } 653 + JS_FreeValue(cx, sound_obj); 654 + } 655 + 656 + // Minimal console so piece code that logs doesn't blow up. 657 + JSValue console = JS_NewObject(cx); 658 + JS_SetPropertyStr(cx, console, "log", JS_NewCFunction(cx, js_console_log, "log", 0)); 659 + JS_SetPropertyStr(cx, console, "warn", JS_NewCFunction(cx, js_console_log, "warn", 0)); 660 + JS_SetPropertyStr(cx, console, "error", JS_NewCFunction(cx, js_console_log, "error", 0)); 661 + JS_SetPropertyStr(cx, global, "console", console); 662 + 663 + // performance.now() shim (notepat and others use it for timing). 664 + JSValue perf = JS_NewObject(cx); 665 + JS_SetPropertyStr(cx, perf, "now", JS_NewCFunction(cx, js_performance_now, "now", 0)); 666 + JS_SetPropertyStr(cx, global, "performance", perf); 667 + 668 + // Dark-mode binding + seed. The JS __theme.update() reads __theme_dark(); 669 + // seeding __theme.dark here means the very first paint already matches 670 + // the system appearance (before the piece's 5-second poll kicks in). 671 + JS_SetPropertyStr(cx, global, "__theme_dark", 672 + JS_NewCFunction(cx, js_theme_dark, "__theme_dark", 0)); 673 + { 674 + JSValue theme = JS_GetPropertyStr(cx, global, "__theme"); 675 + if (JS_IsObject(theme)) { 676 + bool is_dark = (SDL_GetSystemTheme() == SDL_SYSTEM_THEME_DARK); 677 + JS_SetPropertyStr(cx, theme, "dark", JS_NewBool(cx, is_dark)); 678 + } 679 + JS_FreeValue(cx, theme); 680 + } 681 + 682 + // Resolve lib root. Env var wins; otherwise walk up looking for 683 + // system/public/aesthetic.computer/lib relative to the piece path. 684 + const char *env_lib = getenv("AC_LIB_PATH"); 685 + if (env_lib && *env_lib) { 686 + snprintf(pc->lib_path, sizeof(pc->lib_path), "%s", env_lib); 687 + } else { 688 + // Piece is typically at fedac/native/pieces/<name>.mjs; lib is at 689 + // system/public/aesthetic.computer/lib at the repo root. 690 + snprintf(pc->lib_path, sizeof(pc->lib_path), 691 + "../../../system/public/aesthetic.computer/lib"); 692 + } 693 + 694 + // Wire up module loader so `import "/lib/percussion.mjs"` works. 695 + JS_SetModuleLoaderFunc(pc->rt, NULL, piece_module_loader, pc); 696 + 697 + size_t src_len = 0; 698 + char *src = read_file(path, &src_len); 699 + if (!src) { 700 + fprintf(stderr, "[piece] cannot read %s\n", path); 701 + JS_FreeValue(cx, global); 702 + piece_destroy(pc); 703 + return NULL; 704 + } 705 + 706 + // Register the piece under a stable module name so a subsequent 707 + // bootstrap module can re-import it by name. QuickJS 2024-01-13 708 + // doesn't expose a public JS_GetModuleNamespace, so we use a small 709 + // glue module to copy exports onto globalThis. 710 + const char *piece_module_name = "__piece"; 711 + JSValue ret = JS_Eval(cx, src, src_len, piece_module_name, JS_EVAL_TYPE_MODULE); 712 + free(src); 713 + if (JS_IsException(ret)) { 714 + JSValue exc = JS_GetException(cx); 715 + const char *msg = JS_ToCString(cx, exc); 716 + fprintf(stderr, "[piece] eval error: %s\n", msg ? msg : "(unknown)"); 717 + if (msg) JS_FreeCString(cx, msg); 718 + JS_FreeValue(cx, exc); 719 + JS_FreeValue(cx, ret); 720 + JS_FreeValue(cx, global); 721 + piece_destroy(pc); 722 + return NULL; 723 + } 724 + JS_FreeValue(cx, ret); 725 + 726 + // Drain any async jobs (module init promises, etc.). 727 + for (int i = 0; i < 100; i++) { 728 + JSContext *ctx1; 729 + int r = JS_ExecutePendingJob(pc->rt, &ctx1); 730 + if (r == 0) break; 731 + if (r < 0) { 732 + JSValue exc = JS_GetException(ctx1); 733 + const char *msg = JS_ToCString(ctx1, exc); 734 + fprintf(stderr, "[piece] pending job error: %s\n", msg ? msg : "(unknown)"); 735 + if (msg) JS_FreeCString(ctx1, msg); 736 + JS_FreeValue(ctx1, exc); 737 + break; 738 + } 739 + } 740 + 741 + // Bootstrap: re-import the piece by name and copy lifecycle exports 742 + // to globalThis so we can fetch them with JS_GetPropertyStr. 743 + const char *glue = 744 + "import * as __p from \"__piece\";\n" 745 + "globalThis.__boot = __p.boot;\n" 746 + "globalThis.__paint = __p.paint;\n" 747 + "globalThis.__act = __p.act;\n" 748 + "globalThis.__sim = __p.sim;\n" 749 + "globalThis.__leave = __p.leave;\n"; 750 + JSValue g = JS_Eval(cx, glue, strlen(glue), "__glue", JS_EVAL_TYPE_MODULE); 751 + if (JS_IsException(g)) { 752 + JSValue exc = JS_GetException(cx); 753 + const char *msg = JS_ToCString(cx, exc); 754 + fprintf(stderr, "[piece] glue error: %s\n", msg ? msg : "(unknown)"); 755 + if (msg) JS_FreeCString(cx, msg); 756 + JS_FreeValue(cx, exc); 757 + JS_FreeValue(cx, g); 758 + JS_FreeValue(cx, global); 759 + piece_destroy(pc); 760 + return NULL; 761 + } 762 + JS_FreeValue(cx, g); 763 + 764 + // Drain again — module imports can queue jobs. 765 + for (int i = 0; i < 100; i++) { 766 + JSContext *ctx1; 767 + int r = JS_ExecutePendingJob(pc->rt, &ctx1); 768 + if (r == 0) break; 769 + if (r < 0) break; 770 + } 771 + 772 + // Refresh global ref since we may have rebound properties. 773 + JS_FreeValue(cx, global); 774 + global = JS_GetGlobalObject(cx); 775 + 776 + pc->boot_fn = JS_GetPropertyStr(cx, global, "__boot"); 777 + pc->paint_fn = JS_GetPropertyStr(cx, global, "__paint"); 778 + pc->sim_fn = JS_GetPropertyStr(cx, global, "__sim"); 779 + pc->act_fn = JS_GetPropertyStr(cx, global, "__act"); 780 + 781 + JS_FreeValue(cx, global); 782 + return pc; 783 + } 784 + 785 + static void call_lifecycle_with_api(PieceCtx *pc, JSValue fn) { 786 + if (JS_IsUndefined(fn) || !JS_IsFunction(pc->jsctx, fn)) return; 787 + JSValue arg = JS_DupValue(pc->jsctx, pc->api); 788 + JSValue ret = JS_Call(pc->jsctx, fn, JS_UNDEFINED, 1, &arg); 789 + if (JS_IsException(ret)) { 790 + JSValue exc = JS_GetException(pc->jsctx); 791 + const char *msg = JS_ToCString(pc->jsctx, exc); 792 + fprintf(stderr, "[piece] runtime error: %s\n", msg ? msg : "(unknown)"); 793 + if (msg) JS_FreeCString(pc->jsctx, msg); 794 + JS_FreeValue(pc->jsctx, exc); 795 + } 796 + JS_FreeValue(pc->jsctx, ret); 797 + JS_FreeValue(pc->jsctx, arg); 798 + } 799 + 800 + void piece_boot(PieceCtx *pc) { call_lifecycle_with_api(pc, pc->boot_fn); } 801 + void piece_paint(PieceCtx *pc) { call_lifecycle_with_api(pc, pc->paint_fn); } 802 + void piece_sim(PieceCtx *pc) { call_lifecycle_with_api(pc, pc->sim_fn); } 803 + 804 + void piece_act(PieceCtx *pc, const PieceEvent *ev) { 805 + if (!pc || !ev) return; 806 + if (JS_IsUndefined(pc->act_fn) || !JS_IsFunction(pc->jsctx, pc->act_fn)) return; 807 + pc->current_event = ev; 808 + 809 + JSContext *cx = pc->jsctx; 810 + JSValue evobj = JS_NewObject(cx); 811 + JS_SetPropertyStr(cx, evobj, "is", JS_NewCFunction(cx, js_event_is, "is", 1)); 812 + JS_SetPropertyStr(cx, evobj, "x", JS_NewInt32(cx, ev->x)); 813 + JS_SetPropertyStr(cx, evobj, "y", JS_NewInt32(cx, ev->y)); 814 + JS_SetPropertyStr(cx, evobj, "type", JS_NewString(cx, ev->type)); 815 + JS_SetPropertyStr(cx, evobj, "key", JS_NewString(cx, ev->key)); 816 + 817 + // Give the act arg `pc->api` as a prototype so `{sound, wifi, system}` 818 + // destructuring resolves through the chain; `event` lives as an own prop. 819 + JSValue arg = JS_NewObjectProto(cx, pc->api); 820 + JS_SetPropertyStr(cx, arg, "event", evobj); 821 + 822 + JSValue ret = JS_Call(cx, pc->act_fn, JS_UNDEFINED, 1, &arg); 823 + if (JS_IsException(ret)) { 824 + JSValue exc = JS_GetException(cx); 825 + const char *msg = JS_ToCString(cx, exc); 826 + fprintf(stderr, "[piece] act error: %s\n", msg ? msg : "(unknown)"); 827 + if (msg) JS_FreeCString(cx, msg); 828 + JS_FreeValue(cx, exc); 829 + } 830 + JS_FreeValue(cx, ret); 831 + JS_FreeValue(cx, arg); 832 + pc->current_event = NULL; 833 + } 834 + 835 + struct Audio *piece_audio(PieceCtx *pc) { return pc ? pc->audio : NULL; } 836 + 837 + void piece_set_overlay(PieceCtx *pc, int on) { if (pc) pc->overlay_mode = on ? 1 : 0; } 838 + 839 + void piece_reframe(PieceCtx *pc, int w, int h) { 840 + if (!pc || !pc->jsctx) return; 841 + JSContext *cx = pc->jsctx; 842 + 843 + // Mutate the existing screen object (same identity the piece destructured). 844 + JSValue scr_api = JS_GetPropertyStr(cx, pc->api, "screen"); 845 + if (JS_IsObject(scr_api)) { 846 + JS_SetPropertyStr(cx, scr_api, "width", JS_NewInt32(cx, w)); 847 + JS_SetPropertyStr(cx, scr_api, "height", JS_NewInt32(cx, h)); 848 + } 849 + JS_FreeValue(cx, scr_api); 850 + 851 + JSValue global = JS_GetGlobalObject(cx); 852 + JSValue scr_glob = JS_GetPropertyStr(cx, global, "screen"); 853 + if (JS_IsObject(scr_glob)) { 854 + JS_SetPropertyStr(cx, scr_glob, "width", JS_NewInt32(cx, w)); 855 + JS_SetPropertyStr(cx, scr_glob, "height", JS_NewInt32(cx, h)); 856 + } 857 + JS_FreeValue(cx, scr_glob); 858 + JS_FreeValue(cx, global); 859 + 860 + // Fire `reframed` so pieces that re-layout on resize have a hook. 861 + PieceEvent ev = {0}; 862 + snprintf(ev.type, sizeof(ev.type), "reframed"); 863 + piece_act(pc, &ev); 864 + } 865 + 866 + void piece_destroy(PieceCtx *pc) { 867 + if (!pc) return; 868 + if (pc->audio) audio_destroy(pc->audio); 869 + if (pc->jsctx) { 870 + JS_FreeValue(pc->jsctx, pc->boot_fn); 871 + JS_FreeValue(pc->jsctx, pc->paint_fn); 872 + JS_FreeValue(pc->jsctx, pc->sim_fn); 873 + JS_FreeValue(pc->jsctx, pc->act_fn); 874 + JS_FreeValue(pc->jsctx, pc->api); 875 + JS_FreeContext(pc->jsctx); 876 + } 877 + if (pc->rt) JS_FreeRuntime(pc->rt); 878 + free(pc); 879 + }
+54
fedac/native/macos/piece.h
··· 1 + // piece.h — QuickJS piece runtime for ac-native macOS host. 2 + // Hosts a single AC piece: loads the source, registers JS bindings that draw 3 + // into a shared ARGB8888 framebuffer, and dispatches lifecycle calls. 4 + #ifndef AC_MACOS_PIECE_H 5 + #define AC_MACOS_PIECE_H 6 + 7 + #include <stdint.h> 8 + 9 + typedef struct { 10 + uint32_t *pixels; 11 + int width; 12 + int height; 13 + int stride; // pixels per row 14 + } PieceFB; 15 + 16 + typedef struct { 17 + // Event fields surfaced to JS via e.is() / e.x / e.y / e.key. 18 + // type examples: "keyboard:down:a", "keyboard:up:space", "touch", "lift", "draw" 19 + // key: for keyboard events, the AC key name ("a", "arrowleft"); empty otherwise. 20 + char type[48]; 21 + char key[32]; 22 + int x, y; 23 + } PieceEvent; 24 + 25 + typedef struct PieceCtx PieceCtx; 26 + 27 + // Create runtime, load source at path, prepare lifecycle fn lookups. 28 + PieceCtx *piece_load(const char *path, PieceFB *fb); 29 + 30 + // Call lifecycle functions (no-ops if the piece doesn't define them). 31 + void piece_boot(PieceCtx *ctx); 32 + void piece_paint(PieceCtx *ctx); 33 + void piece_sim(PieceCtx *ctx); 34 + void piece_act(PieceCtx *ctx, const PieceEvent *ev); 35 + 36 + // Tell the piece the framebuffer has been resized. Updates the JS 37 + // `screen.width`/`.height` the piece sees via destructuring and dispatches 38 + // a synthetic `reframed` event so pieces that need to re-layout can react. 39 + void piece_reframe(PieceCtx *ctx, int w, int h); 40 + 41 + // Expose the audio engine so the host can instrument it (latency tests). 42 + struct Audio; 43 + struct Audio *piece_audio(PieceCtx *ctx); 44 + 45 + // Toggle transparent-overlay rendering. When on, the piece's `wipe(...)` 46 + // fills the framebuffer with alpha=0 (keeps RGB for debug but makes the 47 + // pixels see-through when composited onto a transparent window). Ink and 48 + // subsequent draws remain opaque. 49 + void piece_set_overlay(PieceCtx *ctx, int on); 50 + 51 + // Teardown. 52 + void piece_destroy(PieceCtx *ctx); 53 + 54 + #endif
+88
fedac/native/macos/scripts/make-icon.py
··· 1 + #!/usr/bin/env python3 2 + # make-icon.py — emit a 1024×1024 Notepat icon.png using only stdlib. 3 + # Design: rounded-square teal background, yellow "N" carved out via a simple 4 + # pixel-space rasterizer. Intentionally zero-dep so it runs on any macOS. 5 + # 6 + # Output: absolute path passed as argv[1]. 7 + import struct, sys, zlib, math 8 + 9 + W, H = 1024, 1024 10 + BG = (45, 90, 110, 255) # deep teal 11 + FG = (255, 210, 90, 255) # warm yellow 12 + CORNER = 180 # px radius on the rounded square 13 + 14 + # "N" geometry — left bar, right bar, diagonal. 15 + MARGIN = 220 16 + BAR_W = 110 17 + N_LEFT_X = MARGIN 18 + N_RIGHT_X = W - MARGIN - BAR_W 19 + N_TOP = MARGIN 20 + N_BOT = H - MARGIN 21 + 22 + def inside_rounded_rect(x, y): 23 + # 32px margin so the rounded square doesn't touch the icon edges (macOS 24 + # adds its own mask for menu bar renderings). 25 + pad = 24 26 + if x < pad or x >= W - pad or y < pad or y >= H - pad: 27 + return False 28 + # Corner cutouts 29 + cx, cy = None, None 30 + if x < pad + CORNER and y < pad + CORNER: 31 + cx, cy = pad + CORNER, pad + CORNER 32 + elif x >= W - pad - CORNER and y < pad + CORNER: 33 + cx, cy = W - pad - CORNER - 1, pad + CORNER 34 + elif x < pad + CORNER and y >= H - pad - CORNER: 35 + cx, cy = pad + CORNER, H - pad - CORNER - 1 36 + elif x >= W - pad - CORNER and y >= H - pad - CORNER: 37 + cx, cy = W - pad - CORNER - 1, H - pad - CORNER - 1 38 + if cx is not None: 39 + dx = x - cx; dy = y - cy 40 + return dx*dx + dy*dy <= CORNER*CORNER 41 + return True 42 + 43 + def inside_n(x, y): 44 + if y < N_TOP or y >= N_BOT: 45 + return False 46 + # Left bar 47 + if N_LEFT_X <= x < N_LEFT_X + BAR_W: 48 + return True 49 + # Right bar 50 + if N_RIGHT_X <= x < N_RIGHT_X + BAR_W: 51 + return True 52 + # Diagonal — from top-left inner edge to bottom-right inner edge 53 + span = N_BOT - N_TOP 54 + t = (y - N_TOP) / span 55 + dx = N_LEFT_X + BAR_W + t * (N_RIGHT_X - (N_LEFT_X + BAR_W)) 56 + if abs(x - dx) <= BAR_W * 0.7: 57 + return True 58 + return False 59 + 60 + pixels = bytearray() 61 + for y in range(H): 62 + row = bytearray() 63 + for x in range(W): 64 + if not inside_rounded_rect(x, y): 65 + row += bytes((0, 0, 0, 0)) 66 + elif inside_n(x, y): 67 + row += bytes(FG) 68 + else: 69 + row += bytes(BG) 70 + pixels += row 71 + 72 + def chunk(tag, data): 73 + crc = zlib.crc32(tag + data) & 0xFFFFFFFF 74 + return struct.pack(">I", len(data)) + tag + data + struct.pack(">I", crc) 75 + 76 + header = b"\x89PNG\r\n\x1a\n" 77 + ihdr = struct.pack(">IIBBBBB", W, H, 8, 6, 0, 0, 0) # RGBA8 78 + raw = bytearray() 79 + stride = W * 4 80 + for y in range(H): 81 + raw.append(0) # filter: None 82 + raw += pixels[y * stride : (y + 1) * stride] 83 + idat = zlib.compress(bytes(raw), 9) 84 + 85 + png = header + chunk(b"IHDR", ihdr) + chunk(b"IDAT", idat) + chunk(b"IEND", b"") 86 + with open(sys.argv[1], "wb") as f: 87 + f.write(png) 88 + print(f"wrote {sys.argv[1]} ({len(png)} bytes)")
+8 -2
fedac/native/pieces/notepat.mjs
··· 363 363 return (now.getUTCHours() - getLAOffset() + 24) % 24; 364 364 } 365 365 function isDark() { 366 + // Host-provided appearance (macOS follows the system Appearance setting) 367 + // wins. When the host doesn't populate __theme_dark (device/Linux build), 368 + // fall back to the LA-clock heuristic: dark after 8pm, light after 7am. 369 + if (typeof globalThis.__theme_dark === "function") { 370 + return !!globalThis.__theme_dark(); 371 + } 366 372 const h = getLAHour(); 367 - return h >= 20 || h < 7; // dark after 8pm, light after 7am (LA time) 373 + return h >= 20 || h < 7; 368 374 } 369 - let dark = isDark(); // auto: dark after 7pm LA time, light before 375 + let dark = isDark(); // auto: macOS appearance if available, else LA time 370 376 371 377 // Background color — average of active notes, lerped 372 378 let bgColor = [0, 0, 0];
+5 -190
fedac/native/src/audio.h
··· 5 5 #include <stdio.h> 6 6 #include <pthread.h> 7 7 #include "audio-decode.h" 8 + // Voice layout, wave/gun enums, and core voice-pool constants live in 9 + // synth_types.h so the macOS SDL3 driver can share them without pulling 10 + // in pthread/alsa via this header. 11 + #include "synth_types.h" 8 12 9 - #define AUDIO_SAMPLE_RATE 192000 10 - #define AUDIO_CHANNELS 2 11 - #define AUDIO_PERIOD_SIZE 192 // ~1ms at 192kHz — minimal latency 12 - #define AUDIO_MAX_VOICES 32 13 - #define AUDIO_WAVEFORM_SIZE 512 14 - #define AUDIO_MAX_SAMPLE_VOICES 12 15 13 #define AUDIO_MAX_SAMPLE_SECS 10 16 14 #define AUDIO_OUTPUT_HISTORY_SECS 12 17 15 #define AUDIO_OUTPUT_HISTORY_RATE 48000 18 16 #define AUDIO_MAX_DECKS 2 19 17 20 - typedef enum { 21 - VOICE_INACTIVE = 0, 22 - VOICE_ACTIVE, 23 - VOICE_KILLING 24 - } VoiceState; 25 - 26 - typedef enum { 27 - WAVE_SINE = 0, 28 - WAVE_TRIANGLE, 29 - WAVE_SAWTOOTH, 30 - WAVE_SQUARE, 31 - WAVE_NOISE, 32 - WAVE_WHISTLE, 33 - WAVE_GUN, 34 - WAVE_HARP 35 - } WaveType; 36 - 37 - // Gun voice presets. Two synthesis models are available per preset: 38 - // GUN_MODEL_CLASSIC — three-layer kick/snare-style synthesis: 39 - // crack (BPF noise burst), boom (sine with downward pitch sweep), 40 - // tail (LPF noise with attack-decay). Cheap, predictable, sounds 41 - // like a "gun sound effect" the way classic sound libraries do. 42 - // GUN_MODEL_PHYSICAL — digital waveguide barrel resonance + body 43 - // modes (parallel biquads) + radiation HPF. Physically motivated; 44 - // better for cavity-dominated sounds (grenade, RPG). 45 - // Per-weapon model choice + parameters live in gun_presets[] in audio.c. 46 - typedef enum { 47 - GUN_MODEL_CLASSIC = 0, 48 - GUN_MODEL_PHYSICAL = 1 49 - } GunModel; 50 - 51 - typedef enum { 52 - GUN_PISTOL = 0, // 9mm — short barrel, bright crack 53 - GUN_RIFLE, // AR/AK — medium barrel + supersonic N-wave 54 - GUN_SHOTGUN, // 12ga — wide bore, heavy low-end 55 - GUN_SMG, // MP5 — short barrel, fast rattle 56 - GUN_SUPPRESSED, // silenced pistol — muffled "pfft" 57 - GUN_LMG, // M60 auto-fire — retriggers while held 58 - GUN_SNIPER, // .50 cal — huge pressure, long tail 59 - GUN_GRENADE, // explosion — low cavity, slow release 60 - GUN_RPG, // rocket — long burn, delayed boom 61 - GUN_RELOAD, // magazine clack — metallic click 62 - GUN_COCK, // bolt cock — two-click (primary + delayed) 63 - GUN_RICOCHET, // metallic ping — pitch-drops on release 64 - GUN_PRESET_COUNT 65 - } GunPreset; 18 + // ACVoice and SampleVoice now live in synth_types.h (shared with macOS SDL3 driver). 66 19 67 - typedef struct { 68 - VoiceState state; 69 - WaveType type; 70 - double phase; // 0.0-1.0 phase accumulator 71 - double frequency; // Hz (smoothed toward target) 72 - double target_frequency; // Hz (set by update, smoothed per sample) 73 - double volume; // 0.0-1.0 74 - double pan; // -1.0 to 1.0 75 - double attack; // seconds 76 - double decay; // seconds (time before end to start fading) 77 - double duration; // seconds (INFINITY for sustained) 78 - double elapsed; // seconds since start 79 - double fade_duration; // for kill(fade) 80 - double fade_elapsed; // progress through fade 81 - double started_at; // monotonic time reference 82 - uint64_t id; // unique voice ID 83 - // Noise filter state 84 - double noise_b0, noise_b1, noise_b2, noise_a1, noise_a2; 85 - double noise_x1, noise_x2, noise_y1, noise_y2; 86 - uint32_t noise_seed; 87 - // Digital waveguide flute/whistle state (Perry Cook STK Flute model) 88 - // See audio.c:generate_whistle_sample for algorithm notes. The bore 89 - // delay line is the primary resonator — its length sets pitch and 90 - // its feedback loop generates all the harmonics. The jet delay + 91 - // cubic nonlinearity drives the loop into sustained oscillation. 92 - double whistle_breath; // envelope-smoothed breath pressure 93 - double whistle_vibrato_phase; // 0..1 vibrato LFO phase 94 - double whistle_lp1; // 1-pole loop LPF state 95 - double whistle_hp_x1, whistle_hp_y1; // 1-pole DC blocker state 96 - // Bore delay line — up to ~2048 samples at 192kHz covers down to ~94 Hz. 97 - // Write cursor advances by 1 each tick; reads use fractional delay 98 - // indexing for smooth pitch. 99 - float whistle_bore_buf[2048]; 100 - int whistle_bore_w; 101 - // Jet delay line — shorter, models embouchure travel time (~0.32×bore). 102 - float whistle_jet_buf[512]; 103 - int whistle_jet_w; 104 - // === Gun DWG state (see generate_gun_sample) === 105 - // Most of these are copied from the preset on note-on; mutable ones 106 - // (pressure_env, body_y1/y2, bore_lp, rad_prev) evolve each sample. 107 - // The bore delay buffer is shared with `whistle_bore_buf` since a 108 - // voice can only be one wave type at a time. 109 - int gun_preset; // GunPreset index (for debug) 110 - double gun_bore_delay; // samples (= bore_length_s * sr) 111 - double gun_bore_loss; // 1-pole LPF alpha in bore loop 112 - double gun_bore_lp; // LPF state 113 - double gun_breech_reflect; // closed-breech reflection gain (0..1) 114 - double gun_pressure; // excitation peak (weapon power) 115 - double gun_pressure_env; // live excitation envelope 0..1 116 - double gun_env_decay_mult; // per-sample decay multiplier (exp) 117 - double gun_noise_gain; // turbulent gas noise modulation depth 118 - double gun_radiation_a; // muzzle HPF 1-zero coefficient (0..1) 119 - double gun_rad_prev; // HPF previous input 120 - // Secondary excitation — fires once more at secondary_trig samples 121 - // elapsed. Used for supersonic N-wave (rifle/sniper) and for the 122 - // second click of a cock/reload two-click gesture. 123 - double gun_secondary_trig; // sample countdown (<=0 = fired) 124 - double gun_secondary_amp; // relative amplitude of 2nd shot 125 - // Sustained fire (LMG) — retrigger the excitation on cadence while 126 - // the voice is held (infinite-duration voice, released via kill). 127 - int gun_sustain_fire; 128 - double gun_retrig_timer; // seconds 129 - double gun_retrig_period; // seconds (60 / RPM) 130 - // Body mode resonators — 3 parallel biquads excited by same pulse. 131 - // Coefficients precomputed from preset on note-on. 132 - double gun_body_a1[3], gun_body_a2[3]; 133 - double gun_body_amp[3]; 134 - double gun_body_y1[3], gun_body_y2[3]; 135 - // Pitch sweep (ricochet) — multiplier applied to bore delay (physical) 136 - // or to boom freq (classic). When voice enters VOICE_KILLING, target 137 - // flips so the bore stretches → doppler drop during release. 138 - double gun_pitch_mult; // current (smoothed) 139 - double gun_pitch_target; // target (set on trigger / release) 140 - double gun_pitch_slew; // per-sample approach rate 141 - // === Gun classic-model state (used when gun_model == GUN_MODEL_CLASSIC) === 142 - // Layered synthesis: crack (BPF noise burst, decays via gun_pressure_env 143 - // and gun_env_decay_mult, filtered through body[0] biquad), boom (pitched 144 - // sine/triangle with exponential pitch sweep + amp decay), tail (LPF noise 145 - // with linear attack ramp + exponential decay, filtered through body[1]). 146 - int gun_model; // GunModel: 0=classic, 1=physical 147 - double gun_boom_phase; // 0..1 oscillator phase 148 - double gun_boom_freq; // current Hz (sweeps toward gun_boom_freq_end) 149 - double gun_boom_freq_start; // Hz at trigger (for LMG sustain-fire retrigger) 150 - double gun_boom_freq_end; // settled Hz (target after pitch sweep) 151 - double gun_boom_pitch_mult; // per-sample geometric approach (closer to 0 = faster) 152 - double gun_boom_env; // amp envelope (decays each sample) 153 - double gun_boom_decay_mult; // per-sample amp decay multiplier 154 - double gun_tail_env; // amp envelope (rises during attack, then decays) 155 - double gun_tail_attack_inc; // per-sample envelope increment during attack (0 = instant) 156 - double gun_tail_decay_mult; // per-sample amp decay multiplier (after attack done) 157 - double gun_crack_b0; // BPF input gain (state lives in body[0]) 158 - double gun_tail_b0, gun_tail_b1, gun_tail_b2; // LPF feed-forward coefs (state in body[1]) 159 - // Click layer — sub-millisecond high-frequency transient. Adds the 160 - // "tk" snap to the front of the envelope so the crack reads as 161 - // crisp instead of as a shaped noise burst. Layered before crack. 162 - double gun_click_env; // amp envelope (decays each sample) 163 - double gun_click_decay_mult; // per-sample multiplier (typ ~exp(-1/(0.5ms*sr))) 164 - double gun_click_amp; // mix gain 165 - double gun_click_prev; // 1-zero HPF state (white_noise[n-1]) 166 - // Physical-model excitation state — Friedlander blast wave shape. 167 - // `t_samples` counts up from 0 each trigger; the muzzle pulse follows 168 - // P(t) = peak·(1−t/t+)·exp(−A·t/t+) for t in [0,t+], then a small 169 - // negative phase, then silence. This replaces the old white-noise + 170 - // exp-decay excitation with the actual shape of a blast wave. 171 - double gun_phys_t; // samples since last trigger 172 - double gun_phys_t_plus; // positive-phase duration (samples) 173 - double gun_phys_friedlander_a; // decay exponent (typ. 1.5) 174 - double gun_phys_neg_amp; // negative-phase peak (relative) 175 - double gun_phys_echo_delay; // ground-reflection delay (samples) 176 - double gun_phys_echo_amp; // ground-reflection gain 177 - double gun_phys_echo_buf[1024]; // small ring for echo tap (~5ms @ 192kHz) 178 - int gun_phys_echo_w; 179 - // === Harp state — Karplus-Strong plucked string === 180 - // References: 181 - // Karplus, K. and Strong, A. (1983). "Digital Synthesis of Plucked- 182 - // String and Drum Timbres," Computer Music Journal 7(2), pp.43-55. 183 - // Jaffe, D.A. and Smith, J.O. (1983). "Extensions of the Karplus-Strong 184 - // Plucked-String Algorithm," Computer Music Journal 7(2), pp.56-69. 185 - // Smith, J.O. "Physical Audio Signal Processing" (online book), 186 - // CCRMA Stanford — https://ccrma.stanford.edu/~jos/pasp/ 187 - // The string delay line reuses `whistle_bore_buf` / `whistle_bore_w` 188 - // since a voice can only be one wave type at a time. `harp_lp1` holds 189 - // the previous sample for the canonical two-point moving-average 190 - // damping filter H(z) = 0.5 + 0.5·z^-1 from Karplus & Strong 1983. 191 - double harp_lp1; 192 - } ACVoice; 193 - 194 - typedef struct { 195 - int active; 196 - int loop; // 1 = loop sample, 0 = one-shot 197 - double position; // fractional sample index 198 - double speed; // playback rate (1.0 = original pitch) 199 - double volume; 200 - double pan; 201 - double fade; // 0-1 envelope 202 - double fade_target; // 0 = killing, 1 = playing 203 - uint64_t id; 204 - } SampleVoice; 205 20 206 21 typedef struct { 207 22 volatile int active; // deck loaded and ready
+974
fedac/native/src/synth_core.c
··· 1 + // synth_core.c — Platform-agnostic synth engine extracted from audio.c. 2 + // All oscillators, gun presets, envelope math, and voice lifecycle helpers 3 + // live here; platform drivers (ALSA on Linux, SDL3 on macOS) own device 4 + // I/O, threading, effects chains, sample playback, and mixing decisions. 5 + // 6 + // Implementation closely mirrors fedac/native/src/audio.c sections for the 7 + // matching features — same constants, same biquad topology. Keep in sync 8 + // with that file until the Linux driver finishes migrating to this core. 9 + 10 + #include "synth_core.h" 11 + 12 + #include <math.h> 13 + #include <string.h> 14 + #include <stdlib.h> 15 + #include <stdint.h> 16 + 17 + #ifndef M_PI 18 + #define M_PI 3.14159265358979323846 19 + #endif 20 + 21 + // ─── Note table / freq parser ─────────────────────────────────────────────── 22 + 23 + static const struct { const char *name; double freq; } note_table[] = { 24 + {"c", 16.3516}, {"cs", 17.3239}, {"db", 17.3239}, 25 + {"d", 18.3540}, {"ds", 19.4454}, {"eb", 19.4454}, 26 + {"e", 20.6017}, {"f", 21.8268}, {"fs", 23.1247}, 27 + {"gb", 23.1247}, {"g", 24.4997}, {"gs", 25.9565}, 28 + {"ab", 25.9565}, {"a", 27.5000}, {"as", 29.1352}, 29 + {"bb", 29.1352}, {"b", 30.8677}, 30 + }; 31 + #define NOTE_TABLE_SIZE (sizeof(note_table) / sizeof(note_table[0])) 32 + 33 + double synth_note_to_freq(const char *note) { 34 + if (!note || !*note) return 440.0; 35 + char *end; 36 + double d = strtod(note, &end); 37 + if (end != note && *end == '\0') return d; 38 + int octave = 4; 39 + char name_buf[8] = {0}; 40 + int ni = 0; 41 + const char *p = note; 42 + if (*p >= '0' && *p <= '9') { octave = *p - '0'; p++; } 43 + while (*p && ni < 3) { 44 + char ch = *p; 45 + if (ch >= 'A' && ch <= 'G') ch += 32; 46 + if ((ch >= 'a' && ch <= 'g') || ch == '#' || ch == 's' || ch == 'b') { 47 + if (ch == '#') name_buf[ni++] = 's'; 48 + else name_buf[ni++] = ch; 49 + p++; 50 + } else break; 51 + } 52 + name_buf[ni] = '\0'; 53 + if (*p >= '0' && *p <= '9') octave = *p - '0'; 54 + double base = 440.0; 55 + for (int i = 0; i < (int)NOTE_TABLE_SIZE; i++) { 56 + if (strcmp(name_buf, note_table[i].name) == 0) { 57 + base = note_table[i].freq; 58 + break; 59 + } 60 + } 61 + return base * pow(2.0, octave); 62 + } 63 + 64 + WaveType synth_parse_wave(const char *s) { 65 + if (!s) return WAVE_SINE; 66 + if (!strcmp(s, "sine")) return WAVE_SINE; 67 + if (!strcmp(s, "triangle")) return WAVE_TRIANGLE; 68 + if (!strcmp(s, "sawtooth")) return WAVE_SAWTOOTH; 69 + if (!strcmp(s, "saw")) return WAVE_SAWTOOTH; 70 + if (!strcmp(s, "square")) return WAVE_SQUARE; 71 + if (!strcmp(s, "noise")) return WAVE_NOISE; 72 + if (!strcmp(s, "whistle")) return WAVE_WHISTLE; 73 + if (!strcmp(s, "gun")) return WAVE_GUN; 74 + return WAVE_SINE; 75 + } 76 + 77 + // ─── Helpers ──────────────────────────────────────────────────────────────── 78 + 79 + static inline uint32_t xorshift32(uint32_t *state) { 80 + uint32_t x = *state; 81 + x ^= x << 13; x ^= x >> 17; x ^= x << 5; 82 + *state = x; 83 + return x; 84 + } 85 + 86 + static inline double clampd(double x, double lo, double hi) { 87 + return x < lo ? lo : (x > hi ? hi : x); 88 + } 89 + 90 + double synth_compute_envelope(const ACVoice *v) { 91 + double env = 1.0; 92 + if (v->attack > 0.0 && v->elapsed < v->attack) { 93 + env = v->elapsed / v->attack; 94 + } 95 + if (!isinf(v->duration) && v->decay > 0.0) { 96 + double decay_start = v->duration - v->decay; 97 + if (decay_start < 0.0) decay_start = 0.0; 98 + if (v->elapsed > decay_start) { 99 + double p = (v->elapsed - decay_start) / v->decay; 100 + if (p > 1.0) p = 1.0; 101 + env *= (1.0 - p); 102 + } 103 + } 104 + return env; 105 + } 106 + 107 + double synth_compute_fade(const ACVoice *v) { 108 + if (v->state != VOICE_KILLING) return 1.0; 109 + if (v->fade_duration <= 0.0) return 0.0; 110 + double progress = v->fade_elapsed / v->fade_duration; 111 + if (progress >= 1.0) return 0.0; 112 + return 1.0 - progress; 113 + } 114 + 115 + // Fractional-delay ring read for whistle/gun-physical bore loops. 116 + static inline double whistle_frac_read(const float *buf, int N, int w, double delay) { 117 + if (delay < 0.0) delay = 0.0; 118 + if (delay > (double)(N - 2)) delay = (double)(N - 2); 119 + double rd = (double)w - delay; 120 + while (rd < 0.0) rd += (double)N; 121 + int i0 = (int)rd; 122 + int i1 = (i0 + 1) % N; 123 + double f = rd - (double)i0; 124 + return (double)buf[i0] * (1.0 - f) + (double)buf[i1] * f; 125 + } 126 + 127 + // ─── Whistle (STK flute DWG) ──────────────────────────────────────────────── 128 + 129 + static inline double generate_whistle_sample(ACVoice *v, double sample_rate) { 130 + double env = synth_compute_envelope(v); 131 + // Lower breath target keeps the flute in its fundamental mode — too 132 + // much breath drives the jet into the over-blow register (second 133 + // harmonic), which reads as an octave shift from the requested tone. 134 + double breath_target = 0.10 + 0.38 * sqrt(env); 135 + double breath_slew = env > v->whistle_breath ? 0.012 : 0.003; 136 + v->whistle_breath += (breath_target - v->whistle_breath) * breath_slew; 137 + 138 + v->whistle_vibrato_phase += 5.0 / sample_rate; 139 + if (v->whistle_vibrato_phase >= 1.0) v->whistle_vibrato_phase -= 1.0; 140 + double vibrato = sin(2.0 * M_PI * v->whistle_vibrato_phase) * 0.03; 141 + 142 + double white = ((double)xorshift32(&v->noise_seed) / (double)UINT32_MAX) * 2.0 - 1.0; 143 + double onset = 1.0 - env; 144 + double noise_gain = 0.08 + 0.05 * onset; 145 + double breath = v->whistle_breath * (1.0 + noise_gain * white + vibrato); 146 + 147 + double freq = clampd(v->frequency, 110.0, sample_rate * 0.20); 148 + double bore_delay = sample_rate / freq; 149 + double jet_delay = bore_delay * 0.32; 150 + const int BORE_N = 2048; 151 + const int JET_N = 512; 152 + if (bore_delay > (double)(BORE_N - 2)) bore_delay = (double)(BORE_N - 2); 153 + if (jet_delay > (double)(JET_N - 2)) jet_delay = (double)(JET_N - 2); 154 + 155 + double bore_out = whistle_frac_read(v->whistle_bore_buf, BORE_N, v->whistle_bore_w, bore_delay); 156 + v->whistle_lp1 = 0.35 * (-bore_out) + 0.65 * v->whistle_lp1; 157 + double temp = v->whistle_lp1; 158 + 159 + double jet_refl = 0.5; 160 + double end_refl = 0.5; 161 + double pd = breath - jet_refl * temp; 162 + v->whistle_jet_buf[v->whistle_jet_w] = (float)pd; 163 + v->whistle_jet_w = (v->whistle_jet_w + 1) % JET_N; 164 + pd = whistle_frac_read(v->whistle_jet_buf, JET_N, v->whistle_jet_w, jet_delay); 165 + 166 + pd = pd * (pd * pd - 1.0); 167 + if (pd > 1.0) pd = 1.0; 168 + if (pd < -1.0) pd = -1.0; 169 + double y = pd - v->whistle_hp_x1 + 0.995 * v->whistle_hp_y1; 170 + v->whistle_hp_x1 = pd; 171 + v->whistle_hp_y1 = y; 172 + 173 + double into_bore = y + end_refl * temp; 174 + v->whistle_bore_buf[v->whistle_bore_w] = (float)into_bore; 175 + v->whistle_bore_w = (v->whistle_bore_w + 1) % BORE_N; 176 + return 0.3 * into_bore; 177 + } 178 + 179 + // ─── Gun presets + init ───────────────────────────────────────────────────── 180 + 181 + typedef struct { 182 + GunModel model; 183 + double master_amp; 184 + double secondary_delay_ms; 185 + double secondary_amp; 186 + int sustain_fire; 187 + double retrig_period_ms; 188 + double click_amp; 189 + double click_decay_ms; 190 + double crack_amp; 191 + double crack_decay_ms; 192 + double crack_fc; 193 + double crack_q; 194 + double boom_amp; 195 + double boom_freq_start; 196 + double boom_freq_end; 197 + double boom_pitch_decay_ms; 198 + double boom_amp_decay_ms; 199 + double tail_amp; 200 + double tail_attack_ms; 201 + double tail_decay_ms; 202 + double tail_fc; 203 + double tail_q; 204 + double bore_length_s; 205 + double bore_loss; 206 + double breech_reflect; 207 + double pressure; 208 + double env_rate; 209 + double noise_gain; 210 + double body_freq[3]; 211 + double body_q[3]; 212 + double body_amp[3]; 213 + double radiation; 214 + } GunPresetParams; 215 + 216 + // Presets ported verbatim from src/audio.c gun_presets[] — keep in sync. 217 + static const GunPresetParams gun_presets[GUN_PRESET_COUNT] = { 218 + [GUN_PISTOL] = { 219 + .model = GUN_MODEL_CLASSIC, .master_amp = 1.1, 220 + .click_amp = 0.65, .click_decay_ms = 0.5, 221 + .crack_amp = 0.95, .crack_decay_ms = 7.0, .crack_fc = 3800, .crack_q = 2.6, 222 + .boom_amp = 0.55, .boom_freq_start = 220, .boom_freq_end = 55, 223 + .boom_pitch_decay_ms = 14, .boom_amp_decay_ms = 55, 224 + .tail_amp = 0.35, .tail_attack_ms = 0, .tail_decay_ms = 110, 225 + .tail_fc = 900, .tail_q = 0.8, 226 + .bore_length_s = 0.000588, .bore_loss = 0.55, .breech_reflect = 0.92, 227 + .pressure = 1.2, .env_rate = 3000.0, .noise_gain = 0.6, 228 + .body_freq = {1500, 4000, 8500}, .body_q = {12, 10, 8}, 229 + .body_amp = {0.30, 0.20, 0.15}, .radiation = 0.985 }, 230 + [GUN_RIFLE] = { 231 + .model = GUN_MODEL_CLASSIC, .master_amp = 1.2, 232 + .click_amp = 0.75, .click_decay_ms = 0.6, 233 + .crack_amp = 1.05, .crack_decay_ms = 8.0, .crack_fc = 4500, .crack_q = 3.0, 234 + .boom_amp = 0.70, .boom_freq_start = 280, .boom_freq_end = 50, 235 + .boom_pitch_decay_ms = 18, .boom_amp_decay_ms = 90, 236 + .tail_amp = 0.45, .tail_attack_ms = 0, .tail_decay_ms = 220, 237 + .tail_fc = 1100, .tail_q = 0.7, 238 + .secondary_delay_ms = 0.9, .secondary_amp = 0.55, 239 + .bore_length_s = 0.00235, .bore_loss = 0.50, .breech_reflect = 0.95, 240 + .pressure = 1.5, .env_rate = 2500.0, .noise_gain = 0.5, 241 + .body_freq = {800, 2400, 6000}, .body_q = {14, 12, 10}, 242 + .body_amp = {0.35, 0.25, 0.15}, .radiation = 0.988 }, 243 + [GUN_SHOTGUN] = { 244 + .model = GUN_MODEL_CLASSIC, .master_amp = 1.4, 245 + .click_amp = 0.55, .click_decay_ms = 0.8, 246 + .crack_amp = 0.65, .crack_decay_ms = 12, .crack_fc = 2200, .crack_q = 1.8, 247 + .boom_amp = 1.10, .boom_freq_start = 260, .boom_freq_end = 38, 248 + .boom_pitch_decay_ms = 22, .boom_amp_decay_ms = 130, 249 + .tail_amp = 0.85, .tail_attack_ms = 4, .tail_decay_ms = 380, 250 + .tail_fc = 700, .tail_q = 0.6, 251 + .bore_length_s = 0.00388, .bore_loss = 0.40, .breech_reflect = 0.88, 252 + .pressure = 1.8, .env_rate = 1800.0, .noise_gain = 0.9, 253 + .body_freq = {400, 1200, 3500}, .body_q = {10, 8, 7}, 254 + .body_amp = {0.40, 0.25, 0.15}, .radiation = 0.965 }, 255 + [GUN_SMG] = { 256 + .model = GUN_MODEL_CLASSIC, .master_amp = 0.95, 257 + .click_amp = 0.55, .click_decay_ms = 0.4, 258 + .crack_amp = 0.85, .crack_decay_ms = 5.0, .crack_fc = 4200, .crack_q = 2.5, 259 + .boom_amp = 0.40, .boom_freq_start = 200, .boom_freq_end = 60, 260 + .boom_pitch_decay_ms = 10, .boom_amp_decay_ms = 40, 261 + .tail_amp = 0.28, .tail_attack_ms = 0, .tail_decay_ms = 80, 262 + .tail_fc = 1200, .tail_q = 0.7, 263 + .sustain_fire = 1, .retrig_period_ms = 60, 264 + .bore_length_s = 0.00132, .bore_loss = 0.58, .breech_reflect = 0.92, 265 + .pressure = 1.0, .env_rate = 3500.0, .noise_gain = 0.5, 266 + .body_freq = {1200, 3500, 7500}, .body_q = {12, 10, 8}, 267 + .body_amp = {0.30, 0.20, 0.13}, .radiation = 0.978 }, 268 + [GUN_SUPPRESSED] = { 269 + .model = GUN_MODEL_CLASSIC, .master_amp = 0.7, 270 + .click_amp = 0.08, .click_decay_ms = 0.4, 271 + .crack_amp = 0.30, .crack_decay_ms = 6.0, .crack_fc = 1600, .crack_q = 1.1, 272 + .boom_amp = 0.10, .boom_freq_start = 150, .boom_freq_end = 80, 273 + .boom_pitch_decay_ms = 8, .boom_amp_decay_ms = 30, 274 + .tail_amp = 0.85, .tail_attack_ms = 6, .tail_decay_ms = 140, 275 + .tail_fc = 1800, .tail_q = 0.6, 276 + .bore_length_s = 0.00100, .bore_loss = 0.85, .breech_reflect = 0.80, 277 + .pressure = 0.5, .env_rate = 1500.0, .noise_gain = 1.0, 278 + .body_freq = {600, 1500, 3000}, .body_q = {6, 5, 4}, 279 + .body_amp = {0.15, 0.10, 0.05}, .radiation = 0.85 }, 280 + [GUN_LMG] = { 281 + .model = GUN_MODEL_CLASSIC, .master_amp = 0.9, 282 + .click_amp = 0.55, .click_decay_ms = 0.5, 283 + .crack_amp = 0.85, .crack_decay_ms = 7.0, .crack_fc = 3500, .crack_q = 2.6, 284 + .boom_amp = 0.65, .boom_freq_start = 250, .boom_freq_end = 48, 285 + .boom_pitch_decay_ms = 16, .boom_amp_decay_ms = 75, 286 + .tail_amp = 0.40, .tail_attack_ms = 0, .tail_decay_ms = 160, 287 + .tail_fc = 950, .tail_q = 0.7, 288 + .sustain_fire = 1, .retrig_period_ms = 100, 289 + .bore_length_s = 0.00329, .bore_loss = 0.48, .breech_reflect = 0.94, 290 + .pressure = 1.4, .env_rate = 2200.0, .noise_gain = 0.55, 291 + .body_freq = {600, 1800, 4500}, .body_q = {12, 10, 8}, 292 + .body_amp = {0.35, 0.25, 0.15}, .radiation = 0.982 }, 293 + [GUN_SNIPER] = { 294 + .model = GUN_MODEL_CLASSIC, .master_amp = 1.5, 295 + .click_amp = 0.85, .click_decay_ms = 0.7, 296 + .crack_amp = 1.20, .crack_decay_ms = 11, .crack_fc = 5000, .crack_q = 3.2, 297 + .boom_amp = 1.20, .boom_freq_start = 320, .boom_freq_end = 36, 298 + .boom_pitch_decay_ms = 28, .boom_amp_decay_ms = 180, 299 + .tail_amp = 0.70, .tail_attack_ms = 3, .tail_decay_ms = 500, 300 + .tail_fc = 850, .tail_q = 0.8, 301 + .secondary_delay_ms = 1.4, .secondary_amp = 0.70, 302 + .bore_length_s = 0.00435, .bore_loss = 0.35, .breech_reflect = 0.97, 303 + .pressure = 2.0, .env_rate = 1500.0, .noise_gain = 0.7, 304 + .body_freq = {350, 950, 2800}, .body_q = {14, 12, 10}, 305 + .body_amp = {0.50, 0.30, 0.15}, .radiation = 0.992 }, 306 + [GUN_GRENADE] = { 307 + .model = GUN_MODEL_PHYSICAL, 308 + .bore_length_s = 0.01000, .bore_loss = 0.25, .breech_reflect = 0.60, 309 + .pressure = 1.6, .env_rate = 400.0, .noise_gain = 1.5, 310 + .body_freq = {80, 250, 1200}, .body_q = {6, 5, 4}, 311 + .body_amp = {0.60, 0.35, 0.15}, .radiation = 0.70, 312 + .master_amp = 1.6, 313 + .click_amp = 0.40, .click_decay_ms = 1.0, 314 + .crack_amp = 0.45, .crack_decay_ms = 25, .crack_fc = 800, .crack_q = 0.7, 315 + .boom_amp = 1.50, .boom_freq_start = 150, .boom_freq_end = 28, 316 + .boom_pitch_decay_ms = 60, .boom_amp_decay_ms = 350, 317 + .tail_amp = 1.50, .tail_attack_ms = 12, .tail_decay_ms = 800, 318 + .tail_fc = 400, .tail_q = 0.4 }, 319 + [GUN_RPG] = { 320 + .model = GUN_MODEL_PHYSICAL, 321 + .bore_length_s = 0.00300, .bore_loss = 0.30, .breech_reflect = 0.50, 322 + .pressure = 1.2, .env_rate = 150.0, .noise_gain = 2.5, 323 + .body_freq = {200, 600, 2000}, .body_q = {4, 3, 3}, 324 + .body_amp = {0.40, 0.30, 0.20}, .radiation = 0.60, 325 + .secondary_delay_ms = 250, .secondary_amp = 1.5, 326 + .master_amp = 1.3, 327 + .click_amp = 0.30, .click_decay_ms = 0.8, 328 + .crack_amp = 0.40, .crack_decay_ms = 20, .crack_fc = 1500, .crack_q = 0.8, 329 + .boom_amp = 0.30, .boom_freq_start = 120, .boom_freq_end = 60, 330 + .boom_pitch_decay_ms = 30, .boom_amp_decay_ms = 100, 331 + .tail_amp = 2.00, .tail_attack_ms = 80, .tail_decay_ms = 600, 332 + .tail_fc = 600, .tail_q = 0.5 }, 333 + [GUN_RELOAD] = { 334 + .model = GUN_MODEL_CLASSIC, .master_amp = 0.75, 335 + .click_amp = 0.85, .click_decay_ms = 0.4, 336 + .crack_amp = 0.90, .crack_decay_ms = 4.0, .crack_fc = 4500, .crack_q = 3.0, 337 + .boom_amp = 0.0, .boom_freq_start = 0, .boom_freq_end = 0, 338 + .boom_pitch_decay_ms = 1, .boom_amp_decay_ms = 1, 339 + .tail_amp = 0.20, .tail_attack_ms = 0, .tail_decay_ms = 30, 340 + .tail_fc = 2500, .tail_q = 0.6, 341 + .secondary_delay_ms = 80, .secondary_amp = 0.65, 342 + .bore_length_s = 0.00010, .bore_loss = 0.70, .breech_reflect = 0.90, 343 + .pressure = 0.6, .env_rate = 4000.0, .noise_gain = 0.3, 344 + .body_freq = {2200, 4500, 8000}, .body_q = {10, 8, 6}, 345 + .body_amp = {0.40, 0.30, 0.15}, .radiation = 0.92 }, 346 + [GUN_COCK] = { 347 + .model = GUN_MODEL_CLASSIC, .master_amp = 0.8, 348 + .click_amp = 0.90, .click_decay_ms = 0.4, 349 + .crack_amp = 1.00, .crack_decay_ms = 5.0, .crack_fc = 3800, .crack_q = 3.2, 350 + .boom_amp = 0.0, .boom_freq_start = 0, .boom_freq_end = 0, 351 + .boom_pitch_decay_ms = 1, .boom_amp_decay_ms = 1, 352 + .tail_amp = 0.15, .tail_attack_ms = 0, .tail_decay_ms = 25, 353 + .tail_fc = 2000, .tail_q = 0.6, 354 + .secondary_delay_ms = 55, .secondary_amp = 0.80, 355 + .bore_length_s = 0.00015, .bore_loss = 0.65, .breech_reflect = 0.88, 356 + .pressure = 0.7, .env_rate = 3500.0, .noise_gain = 0.35, 357 + .body_freq = {1800, 4200, 7500}, .body_q = {10, 8, 7}, 358 + .body_amp = {0.45, 0.25, 0.15}, .radiation = 0.92 }, 359 + [GUN_RICOCHET] = { 360 + .model = GUN_MODEL_CLASSIC, .master_amp = 0.85, 361 + .click_amp = 0.40, .click_decay_ms = 0.5, 362 + .crack_amp = 0.35, .crack_decay_ms = 7.0, .crack_fc = 5500, .crack_q = 3.0, 363 + .boom_amp = 0.95, .boom_freq_start = 1800,.boom_freq_end = 1500, 364 + .boom_pitch_decay_ms = 60, .boom_amp_decay_ms = 350, 365 + .tail_amp = 0.20, .tail_attack_ms = 0, .tail_decay_ms = 200, 366 + .tail_fc = 3000, .tail_q = 1.0, 367 + .bore_length_s = 0.00040, .bore_loss = 0.15, .breech_reflect = 0.90, 368 + .pressure = 0.8, .env_rate = 600.0, .noise_gain = 0.3, 369 + .body_freq = {3000, 5500, 9000}, .body_q = {30, 25, 20}, 370 + .body_amp = {0.40, 0.25, 0.15}, .radiation = 0.975 }, 371 + }; 372 + 373 + static inline void compute_resonator(double f, double q, double sr, 374 + double *a1, double *a2, double *b0) { 375 + if (q < 0.4) q = 0.4; 376 + if (f < 20.0) f = 20.0; 377 + if (f > sr * 0.45) f = sr * 0.45; 378 + double r = exp(-M_PI * f / (q * sr)); 379 + double w = 2.0 * M_PI * f / sr; 380 + *a1 = 2.0 * r * cos(w); 381 + *a2 = r * r; 382 + *b0 = (1.0 - r); 383 + } 384 + 385 + static void gun_init_voice(ACVoice *v, GunPreset preset, double sr, int force_model) { 386 + if (preset < 0 || preset >= GUN_PRESET_COUNT) preset = GUN_PISTOL; 387 + const GunPresetParams *p = &gun_presets[preset]; 388 + 389 + v->gun_preset = (int)preset; 390 + v->gun_model = (force_model == 0 || force_model == 1) ? force_model : (int)p->model; 391 + v->gun_pressure = p->master_amp > 0.0 ? p->master_amp : 1.0; 392 + v->gun_pressure_env = p->sustain_fire ? 0.92 : 1.0; 393 + v->gun_secondary_trig = p->secondary_delay_ms > 0 394 + ? p->secondary_delay_ms * 0.001 * sr : 0.0; 395 + v->gun_secondary_amp = p->secondary_amp; 396 + v->gun_sustain_fire = p->sustain_fire; 397 + v->gun_retrig_timer = 0.0; 398 + v->gun_retrig_period = p->retrig_period_ms * 0.001; 399 + v->gun_pitch_mult = 1.0; 400 + v->gun_pitch_target = 1.0; 401 + v->gun_pitch_slew = 1.0 / (0.3 * sr); 402 + 403 + if (v->gun_model == GUN_MODEL_CLASSIC) { 404 + double tau_crack = (p->crack_decay_ms > 0.1 ? p->crack_decay_ms : 0.1) * 0.001; 405 + v->gun_env_decay_mult = exp(-1.0 / (tau_crack * sr)); 406 + v->gun_boom_freq_start = p->boom_freq_start; 407 + v->gun_boom_freq_end = p->boom_freq_end; 408 + v->gun_boom_freq = p->boom_freq_start; 409 + v->gun_boom_phase = 0.0; 410 + double tau_pitch = (p->boom_pitch_decay_ms > 0.1 ? p->boom_pitch_decay_ms : 0.1) * 0.001; 411 + v->gun_boom_pitch_mult = exp(-1.0 / (tau_pitch * sr)); 412 + double tau_boom = (p->boom_amp_decay_ms > 0.1 ? p->boom_amp_decay_ms : 0.1) * 0.001; 413 + v->gun_boom_decay_mult = exp(-1.0 / (tau_boom * sr)); 414 + v->gun_boom_env = (p->boom_amp > 0.0) ? (p->sustain_fire ? 0.92 : 1.0) : 0.0; 415 + v->gun_tail_env = (p->tail_attack_ms > 0.0) ? 0.0 : 1.0; 416 + v->gun_tail_attack_inc = (p->tail_attack_ms > 0.0) 417 + ? 1.0 / (p->tail_attack_ms * 0.001 * sr) : 0.0; 418 + double tau_tail = (p->tail_decay_ms > 0.1 ? p->tail_decay_ms : 0.1) * 0.001; 419 + v->gun_tail_decay_mult = exp(-1.0 / (tau_tail * sr)); 420 + compute_resonator(p->crack_fc, p->crack_q, sr, 421 + &v->gun_body_a1[0], &v->gun_body_a2[0], &v->gun_crack_b0); 422 + compute_resonator(p->tail_fc, p->tail_q, sr, 423 + &v->gun_body_a1[1], &v->gun_body_a2[1], &v->gun_tail_b0); 424 + v->gun_tail_b1 = 0.0; v->gun_tail_b2 = 0.0; 425 + for (int i = 0; i < 3; i++) { v->gun_body_y1[i] = 0.0; v->gun_body_y2[i] = 0.0; } 426 + v->gun_body_amp[0] = p->crack_amp; 427 + v->gun_body_amp[1] = p->boom_amp; 428 + v->gun_body_amp[2] = p->tail_amp; 429 + v->gun_click_amp = p->click_amp; 430 + v->gun_click_env = (p->click_amp > 0.0) ? (p->sustain_fire ? 0.92 : 1.0) : 0.0; 431 + v->gun_click_prev = 0.0; 432 + double tau_click = (p->click_decay_ms > 0.05 ? p->click_decay_ms : 0.05) * 0.001; 433 + v->gun_click_decay_mult = exp(-1.0 / (tau_click * sr)); 434 + v->gun_bore_delay = 0.0; 435 + v->gun_bore_loss = 0.0; 436 + v->gun_bore_lp = 0.0; 437 + v->gun_breech_reflect = 0.0; 438 + v->gun_noise_gain = 0.0; 439 + v->gun_radiation_a = 0.0; 440 + v->gun_rad_prev = 0.0; 441 + memset(v->whistle_bore_buf, 0, sizeof(v->whistle_bore_buf)); 442 + v->whistle_bore_w = 0; 443 + } else { 444 + v->gun_bore_delay = p->bore_length_s * sr; 445 + if (v->gun_bore_delay < 4.0) v->gun_bore_delay = 4.0; 446 + if (v->gun_bore_delay > 2040.0) v->gun_bore_delay = 2040.0; 447 + v->gun_bore_loss = p->bore_loss; 448 + v->gun_bore_lp = 0.0; 449 + v->gun_breech_reflect = p->breech_reflect; 450 + v->gun_pressure = p->pressure; 451 + v->gun_env_decay_mult = exp(-p->env_rate / sr); 452 + v->gun_noise_gain = p->noise_gain; 453 + v->gun_radiation_a = p->radiation; 454 + v->gun_rad_prev = 0.0; 455 + for (int i = 0; i < 3; i++) { 456 + double a1, a2, b0_unused; 457 + compute_resonator(p->body_freq[i], p->body_q[i], sr, &a1, &a2, &b0_unused); 458 + v->gun_body_a1[i] = a1; 459 + v->gun_body_a2[i] = a2; 460 + v->gun_body_amp[i] = p->body_amp[i]; 461 + v->gun_body_y1[i] = 0.0; 462 + v->gun_body_y2[i] = 0.0; 463 + } 464 + memset(v->whistle_bore_buf, 0, sizeof(v->whistle_bore_buf)); 465 + v->whistle_bore_w = 0; 466 + v->gun_phys_t = 0.0; 467 + v->gun_phys_t_plus = (3.0 / (p->env_rate > 100 ? p->env_rate : 100.0)) * sr; 468 + if (v->gun_phys_t_plus < 32.0) v->gun_phys_t_plus = 32.0; 469 + if (v->gun_phys_t_plus > 4096.0) v->gun_phys_t_plus = 4096.0; 470 + v->gun_phys_friedlander_a = 1.5; 471 + v->gun_phys_neg_amp = 0.18; 472 + v->gun_phys_echo_delay = 0.0035 * sr; 473 + if (v->gun_phys_echo_delay > 1023.0) v->gun_phys_echo_delay = 1023.0; 474 + v->gun_phys_echo_amp = 0.22; 475 + memset(v->gun_phys_echo_buf, 0, sizeof(v->gun_phys_echo_buf)); 476 + v->gun_phys_echo_w = 0; 477 + v->gun_boom_phase = 0.0; 478 + v->gun_boom_freq = 0.0; 479 + v->gun_boom_freq_start = 0.0; 480 + v->gun_boom_freq_end = 0.0; 481 + v->gun_boom_pitch_mult = 1.0; 482 + v->gun_boom_env = 0.0; 483 + v->gun_boom_decay_mult = 1.0; 484 + v->gun_tail_env = 0.0; 485 + v->gun_tail_attack_inc = 0.0; 486 + v->gun_tail_decay_mult = 1.0; 487 + v->gun_crack_b0 = 0.0; 488 + v->gun_tail_b0 = v->gun_tail_b1 = v->gun_tail_b2 = 0.0; 489 + v->gun_click_amp = 0.0; 490 + v->gun_click_env = 0.0; 491 + v->gun_click_decay_mult = 1.0; 492 + v->gun_click_prev = 0.0; 493 + } 494 + } 495 + 496 + static inline void gun_on_release(ACVoice *v) { 497 + if (v->type != WAVE_GUN) return; 498 + if (v->gun_preset == GUN_RICOCHET) { 499 + v->gun_pitch_target = (v->gun_model == GUN_MODEL_CLASSIC) ? 0.35 : 2.8; 500 + } 501 + } 502 + 503 + // ─── Gun classic (3-layer) ────────────────────────────────────────────────── 504 + 505 + static inline double generate_gun_classic_sample(ACVoice *v, double sr) { 506 + if (v->gun_secondary_trig > 0.0) { 507 + v->gun_secondary_trig -= 1.0; 508 + if (v->gun_secondary_trig <= 0.0) { 509 + v->gun_pressure_env = v->gun_secondary_amp; 510 + v->gun_boom_env = v->gun_secondary_amp * 0.6; 511 + v->gun_click_env = v->gun_secondary_amp; 512 + v->gun_secondary_trig = 0.0; 513 + } 514 + } 515 + if (v->gun_sustain_fire && v->state == VOICE_ACTIVE 516 + && isinf(v->duration) && v->gun_retrig_period > 0.0) { 517 + v->gun_retrig_timer += 1.0 / sr; 518 + if (v->gun_retrig_timer >= v->gun_retrig_period) { 519 + v->gun_retrig_timer -= v->gun_retrig_period; 520 + double j = (double)xorshift32(&v->noise_seed) / (double)UINT32_MAX; 521 + double jitter = 0.82 + j * 0.32; 522 + v->gun_pressure_env = jitter; 523 + v->gun_boom_env = jitter; 524 + v->gun_click_env = jitter; 525 + v->gun_boom_freq = v->gun_boom_freq_start; 526 + } 527 + } 528 + if (v->gun_pitch_mult != v->gun_pitch_target) { 529 + v->gun_pitch_mult += (v->gun_pitch_target - v->gun_pitch_mult) * 0.00012; 530 + } 531 + 532 + double click = 0.0; 533 + if (v->gun_click_env > 0.00002 && v->gun_click_amp > 0.0) { 534 + double white = ((double)xorshift32(&v->noise_seed) / (double)UINT32_MAX) * 2.0 - 1.0; 535 + double hp = white - v->gun_click_prev; 536 + v->gun_click_prev = white; 537 + click = hp * v->gun_click_env * v->gun_click_amp; 538 + v->gun_click_env *= v->gun_click_decay_mult; 539 + } 540 + double crack = 0.0; 541 + if (v->gun_pressure_env > 0.00002 && v->gun_body_amp[0] > 0.0) { 542 + double white = ((double)xorshift32(&v->noise_seed) / (double)UINT32_MAX) * 2.0 - 1.0; 543 + double y = v->gun_crack_b0 * white 544 + + v->gun_body_a1[0] * v->gun_body_y1[0] 545 + - v->gun_body_a2[0] * v->gun_body_y2[0]; 546 + v->gun_body_y2[0] = v->gun_body_y1[0]; 547 + v->gun_body_y1[0] = y; 548 + crack = y * v->gun_pressure_env * v->gun_body_amp[0]; 549 + v->gun_pressure_env *= v->gun_env_decay_mult; 550 + } 551 + double boom = 0.0; 552 + if (v->gun_boom_env > 0.00002 && v->gun_body_amp[1] > 0.0) { 553 + v->gun_boom_freq = v->gun_boom_freq_end 554 + + (v->gun_boom_freq - v->gun_boom_freq_end) * v->gun_boom_pitch_mult; 555 + double f = v->gun_boom_freq * v->gun_pitch_mult; 556 + if (f < 1.0) f = 1.0; 557 + v->gun_boom_phase += f / sr; 558 + if (v->gun_boom_phase >= 1.0) v->gun_boom_phase -= 1.0; 559 + if (v->gun_boom_phase < 0.0) v->gun_boom_phase += 1.0; 560 + double tp = v->gun_boom_phase; 561 + double s = (tp < 0.5) ? (4.0 * tp - 1.0) : (3.0 - 4.0 * tp); 562 + boom = s * v->gun_boom_env * v->gun_body_amp[1]; 563 + v->gun_boom_env *= v->gun_boom_decay_mult; 564 + } 565 + double tail = 0.0; 566 + if (v->gun_body_amp[2] > 0.0) { 567 + if (v->gun_tail_attack_inc > 0.0) { 568 + v->gun_tail_env += v->gun_tail_attack_inc; 569 + if (v->gun_tail_env >= 1.0) { 570 + v->gun_tail_env = 1.0; 571 + v->gun_tail_attack_inc = 0.0; 572 + } 573 + } else if (v->gun_tail_env > 0.00001) { 574 + v->gun_tail_env *= v->gun_tail_decay_mult; 575 + } 576 + if (v->gun_tail_env > 0.00001) { 577 + double white = ((double)xorshift32(&v->noise_seed) / (double)UINT32_MAX) * 2.0 - 1.0; 578 + double y = v->gun_tail_b0 * white 579 + + v->gun_body_a1[1] * v->gun_body_y1[1] 580 + - v->gun_body_a2[1] * v->gun_body_y2[1]; 581 + v->gun_body_y2[1] = v->gun_body_y1[1]; 582 + v->gun_body_y1[1] = y; 583 + tail = y * v->gun_tail_env * v->gun_body_amp[2]; 584 + } 585 + } 586 + 587 + double out = (click + crack + boom + tail) * v->gun_pressure; 588 + return out * synth_compute_envelope(v); 589 + } 590 + 591 + // ─── Gun physical (DWG bore + body modes) ─────────────────────────────────── 592 + 593 + static inline double generate_gun_physical_sample(ACVoice *v, double sr) { 594 + double t = v->gun_phys_t; 595 + double t_plus = v->gun_phys_t_plus; 596 + double A = v->gun_phys_friedlander_a; 597 + double pulse = 0.0; 598 + if (t < t_plus) { 599 + double f = t / t_plus; 600 + pulse = (1.0 - f) * exp(-A * f); 601 + } else if (t < t_plus * 5.0) { 602 + double tn = (t - t_plus) / (t_plus * 4.0); 603 + pulse = -v->gun_phys_neg_amp * (1.0 - tn) * exp(-2.0 * tn); 604 + } 605 + uint32_t n = xorshift32(&v->noise_seed); 606 + double white = ((double)n / (double)UINT32_MAX) * 2.0 - 1.0; 607 + double excite = v->gun_pressure * pulse * (1.0 + v->gun_noise_gain * white); 608 + v->gun_phys_t += 1.0; 609 + 610 + if (v->gun_secondary_trig > 0.0) { 611 + v->gun_secondary_trig -= 1.0; 612 + if (v->gun_secondary_trig <= 0.0) { 613 + v->gun_phys_t = 0.0; 614 + v->gun_pressure *= v->gun_secondary_amp; 615 + v->gun_secondary_trig = 0.0; 616 + } 617 + } 618 + if (v->gun_sustain_fire && v->state == VOICE_ACTIVE 619 + && isinf(v->duration) && v->gun_retrig_period > 0.0) { 620 + v->gun_retrig_timer += 1.0 / sr; 621 + if (v->gun_retrig_timer >= v->gun_retrig_period) { 622 + v->gun_retrig_timer -= v->gun_retrig_period; 623 + v->gun_phys_t = 0.0; 624 + double j = (double)xorshift32(&v->noise_seed) / (double)UINT32_MAX; 625 + v->gun_pressure *= 0.92 + j * 0.16; 626 + } 627 + } 628 + if (v->gun_pitch_mult != v->gun_pitch_target) { 629 + v->gun_pitch_mult += (v->gun_pitch_target - v->gun_pitch_mult) * 0.00012; 630 + } 631 + double bore_delay = v->gun_bore_delay * v->gun_pitch_mult; 632 + if (bore_delay < 4.0) bore_delay = 4.0; 633 + if (bore_delay > 2040.0) bore_delay = 2040.0; 634 + 635 + const int BORE_N = 2048; 636 + double bore_out = whistle_frac_read(v->whistle_bore_buf, BORE_N, v->whistle_bore_w, bore_delay); 637 + v->gun_bore_lp = v->gun_bore_loss * (-bore_out) 638 + + (1.0 - v->gun_bore_loss) * v->gun_bore_lp; 639 + double refl = v->gun_bore_lp; 640 + double into_bore = excite + refl * v->gun_breech_reflect; 641 + v->whistle_bore_buf[v->whistle_bore_w] = (float)into_bore; 642 + v->whistle_bore_w = (v->whistle_bore_w + 1) % BORE_N; 643 + 644 + double radiated = into_bore - v->gun_radiation_a * v->gun_rad_prev; 645 + v->gun_rad_prev = into_bore; 646 + 647 + double body = 0.0; 648 + for (int i = 0; i < 3; i++) { 649 + double y = excite + v->gun_body_a1[i] * v->gun_body_y1[i] 650 + - v->gun_body_a2[i] * v->gun_body_y2[i]; 651 + v->gun_body_y2[i] = v->gun_body_y1[i]; 652 + v->gun_body_y1[i] = y; 653 + body += y * v->gun_body_amp[i]; 654 + } 655 + double dry = radiated * 0.55 + body * 0.45; 656 + 657 + double echo_out = 0.0; 658 + if (v->gun_phys_echo_amp > 0.0 && v->gun_phys_echo_delay > 1.0) { 659 + const int ECHO_N = 1024; 660 + int read_pos = v->gun_phys_echo_w - (int)v->gun_phys_echo_delay; 661 + while (read_pos < 0) read_pos += ECHO_N; 662 + echo_out = (double)v->gun_phys_echo_buf[read_pos % ECHO_N] * v->gun_phys_echo_amp; 663 + v->gun_phys_echo_buf[v->gun_phys_echo_w] = (float)dry; 664 + v->gun_phys_echo_w = (v->gun_phys_echo_w + 1) % ECHO_N; 665 + } 666 + return (dry + echo_out) * synth_compute_envelope(v); 667 + } 668 + 669 + static inline double generate_gun_sample(ACVoice *v, double sr) { 670 + return (v->gun_model == GUN_MODEL_PHYSICAL) 671 + ? generate_gun_physical_sample(v, sr) 672 + : generate_gun_classic_sample(v, sr); 673 + } 674 + 675 + // ─── Main per-sample generator ────────────────────────────────────────────── 676 + 677 + double synth_generate_sample(ACVoice *v, double sample_rate) { 678 + double s = 0.0; 679 + switch (v->type) { 680 + case WAVE_SINE: s = sin(2.0 * M_PI * v->phase); break; 681 + case WAVE_SQUARE: s = v->phase < 0.5 ? 1.0 : -1.0; break; 682 + case WAVE_TRIANGLE: { 683 + double tp = v->phase + 0.25; 684 + if (tp >= 1.0) tp -= 1.0; 685 + s = 4.0 * fabs(tp - 0.5) - 1.0; 686 + break; 687 + } 688 + case WAVE_SAWTOOTH: s = 2.0 * v->phase - 1.0; break; 689 + case WAVE_NOISE: { 690 + double white = ((double)xorshift32(&v->noise_seed) / (double)UINT32_MAX) * 2.0 - 1.0; 691 + double y = v->noise_b0 * white + v->noise_b1 * v->noise_x1 + v->noise_b2 * v->noise_x2 692 + - v->noise_a1 * v->noise_y1 - v->noise_a2 * v->noise_y2; 693 + v->noise_x2 = v->noise_x1; v->noise_x1 = white; 694 + v->noise_y2 = v->noise_y1; v->noise_y1 = y; 695 + s = y; 696 + break; 697 + } 698 + case WAVE_WHISTLE: s = generate_whistle_sample(v, sample_rate); break; 699 + case WAVE_GUN: s = generate_gun_sample(v, sample_rate); break; 700 + default: s = 0.0; 701 + } 702 + if (v->target_frequency > 0 && v->frequency != v->target_frequency) { 703 + v->frequency += (v->target_frequency - v->frequency) * 0.0003; 704 + } 705 + if (v->type != WAVE_WHISTLE && v->type != WAVE_GUN) { 706 + v->phase += v->frequency / sample_rate; 707 + if (v->phase >= 1.0) v->phase -= 1.0; 708 + } 709 + return s; 710 + } 711 + 712 + void synth_setup_noise_filter(ACVoice *v, double sample_rate) { 713 + double cutoff = v->frequency; 714 + if (cutoff < 20.0) cutoff = 20.0; 715 + if (cutoff > sample_rate / 2.0) cutoff = sample_rate / 2.0; 716 + double Q = 1.0; 717 + double w0 = 2.0 * M_PI * cutoff / sample_rate; 718 + double alpha = sin(w0) / (2.0 * Q); 719 + double b0 = (1.0 - cos(w0)) / 2.0; 720 + double b1 = 1.0 - cos(w0); 721 + double b2 = (1.0 - cos(w0)) / 2.0; 722 + double a0 = 1.0 + alpha; 723 + double a1 = -2.0 * cos(w0); 724 + double a2 = 1.0 - alpha; 725 + v->noise_b0 = b0 / a0; 726 + v->noise_b1 = b1 / a0; 727 + v->noise_b2 = b2 / a0; 728 + v->noise_a1 = a1 / a0; 729 + v->noise_a2 = a2 / a0; 730 + v->noise_x1 = v->noise_x2 = v->noise_y1 = v->noise_y2 = 0.0; 731 + } 732 + 733 + // ─── Voice lifecycle ──────────────────────────────────────────────────────── 734 + 735 + void synth_core_init(SynthCore *s, ACVoice *voices, int max_voices, 736 + pthread_mutex_t *lock, uint64_t *next_id, double sr) { 737 + s->voices = voices; 738 + s->max_voices = max_voices; 739 + s->lock = lock; 740 + s->next_id = next_id; 741 + s->sample_rate = sr; 742 + if (s->next_id && *s->next_id == 0) *s->next_id = 1; 743 + } 744 + 745 + uint64_t synth_synth(SynthCore *s, WaveType type, double freq, 746 + double duration, double volume, double attack, 747 + double decay, double pan) { 748 + if (!s) return 0; 749 + pthread_mutex_lock(s->lock); 750 + int slot = -1; 751 + for (int i = 0; i < s->max_voices; i++) { 752 + if (s->voices[i].state == VOICE_INACTIVE) { slot = i; break; } 753 + } 754 + if (slot < 0) { 755 + double oldest = 0; slot = 0; 756 + for (int i = 0; i < s->max_voices; i++) { 757 + if (s->voices[i].elapsed > oldest) { oldest = s->voices[i].elapsed; slot = i; } 758 + } 759 + } 760 + ACVoice *v = &s->voices[slot]; 761 + memset(v, 0, sizeof(ACVoice)); 762 + v->state = VOICE_ACTIVE; 763 + v->type = type; 764 + v->phase = 0.0; 765 + v->frequency = freq; 766 + v->target_frequency = freq; 767 + v->volume = volume; 768 + v->pan = pan; 769 + v->attack = attack > 0 ? attack : 0.005; 770 + v->decay = decay > 0 ? decay : 0.1; 771 + v->duration = duration; 772 + v->id = ++(*s->next_id); 773 + 774 + if (type == WAVE_NOISE || type == WAVE_WHISTLE || type == WAVE_GUN) { 775 + v->noise_seed = (uint32_t)(*s->next_id * 2654435761u); 776 + } 777 + if (type == WAVE_NOISE) { 778 + synth_setup_noise_filter(v, s->sample_rate); 779 + } else if (type == WAVE_WHISTLE) { 780 + memset(v->whistle_bore_buf, 0, sizeof(v->whistle_bore_buf)); 781 + memset(v->whistle_jet_buf, 0, sizeof(v->whistle_jet_buf)); 782 + v->whistle_bore_w = 0; 783 + v->whistle_jet_w = 0; 784 + v->whistle_breath = 0.0; 785 + v->whistle_vibrato_phase = 0.0; 786 + v->whistle_lp1 = 0.0; 787 + v->whistle_hp_x1 = 0.0; 788 + v->whistle_hp_y1 = 0.0; 789 + } 790 + uint64_t id = v->id; 791 + pthread_mutex_unlock(s->lock); 792 + return id; 793 + } 794 + 795 + uint64_t synth_synth_gun(SynthCore *s, GunPreset preset, double duration, 796 + double volume, double attack, double decay, 797 + double pan, double pressure_scale, int force_model) { 798 + if (!s) return 0; 799 + uint64_t id = synth_synth(s, WAVE_GUN, 110.0, duration, volume, attack, decay, pan); 800 + if (!id) return 0; 801 + pthread_mutex_lock(s->lock); 802 + for (int i = 0; i < s->max_voices; i++) { 803 + if (s->voices[i].id == id) { 804 + gun_init_voice(&s->voices[i], preset, s->sample_rate, force_model); 805 + if (pressure_scale > 0.0 && pressure_scale != 1.0) { 806 + s->voices[i].gun_pressure *= pressure_scale; 807 + } 808 + break; 809 + } 810 + } 811 + pthread_mutex_unlock(s->lock); 812 + return id; 813 + } 814 + 815 + void synth_kill(SynthCore *s, uint64_t id, double fade) { 816 + if (!s) return; 817 + pthread_mutex_lock(s->lock); 818 + for (int i = 0; i < s->max_voices; i++) { 819 + if (s->voices[i].id == id && s->voices[i].state == VOICE_ACTIVE) { 820 + s->voices[i].state = VOICE_KILLING; 821 + s->voices[i].fade_duration = fade > 0 ? fade : 0.025; 822 + s->voices[i].fade_elapsed = 0.0; 823 + if (s->voices[i].type == WAVE_GUN) gun_on_release(&s->voices[i]); 824 + break; 825 + } 826 + } 827 + pthread_mutex_unlock(s->lock); 828 + } 829 + 830 + void synth_update(SynthCore *s, uint64_t id, double freq, double vol, double pan) { 831 + if (!s) return; 832 + pthread_mutex_lock(s->lock); 833 + for (int i = 0; i < s->max_voices; i++) { 834 + ACVoice *v = &s->voices[i]; 835 + if (v->id == id && v->state != VOICE_INACTIVE) { 836 + if (freq > 0) v->target_frequency = freq; 837 + if (vol >= 0) v->volume = vol; 838 + if (pan > -2.0) v->pan = pan; 839 + break; 840 + } 841 + } 842 + pthread_mutex_unlock(s->lock); 843 + } 844 + 845 + void synth_gun_set_param(SynthCore *s, uint64_t id, const char *key, double value) { 846 + if (!s || !key) return; 847 + pthread_mutex_lock(s->lock); 848 + ACVoice *v = NULL; 849 + for (int i = 0; i < s->max_voices; i++) { 850 + if (s->voices[i].id == id && s->voices[i].type == WAVE_GUN) { 851 + v = &s->voices[i]; break; 852 + } 853 + } 854 + if (!v) { pthread_mutex_unlock(s->lock); return; } 855 + double sr = s->sample_rate; 856 + if (v->gun_model == GUN_MODEL_CLASSIC) { 857 + if (strcmp(key, "click_amp") == 0) v->gun_click_amp = value; 858 + else if (strcmp(key, "click_decay_ms") == 0) { 859 + double tau = (value > 0.05 ? value : 0.05) * 0.001; 860 + v->gun_click_decay_mult = exp(-1.0 / (tau * sr)); 861 + } 862 + else if (strcmp(key, "crack_amp") == 0) v->gun_body_amp[0] = value; 863 + else if (strcmp(key, "crack_decay_ms") == 0) { 864 + double tau = (value > 0.1 ? value : 0.1) * 0.001; 865 + v->gun_env_decay_mult = exp(-1.0 / (tau * sr)); 866 + } 867 + else if (strcmp(key, "crack_fc") == 0 || strcmp(key, "crack_q") == 0) { 868 + double a2 = v->gun_body_a2[0]; 869 + double r = a2 > 0 ? sqrt(a2) : 0.95; 870 + double cur_w = acos(v->gun_body_a1[0] / (2.0 * r)); 871 + double cur_f = cur_w * sr / (2.0 * M_PI); 872 + double cur_q = -M_PI * cur_f / (sr * log(r > 0.0001 ? r : 0.0001)); 873 + double f = (strcmp(key, "crack_fc") == 0) ? value : cur_f; 874 + double q = (strcmp(key, "crack_q") == 0) ? value : cur_q; 875 + compute_resonator(f, q, sr, &v->gun_body_a1[0], &v->gun_body_a2[0], &v->gun_crack_b0); 876 + } 877 + else if (strcmp(key, "boom_amp") == 0) v->gun_body_amp[1] = value; 878 + else if (strcmp(key, "boom_freq_start") == 0) { 879 + v->gun_boom_freq_start = value; v->gun_boom_freq = value; 880 + } 881 + else if (strcmp(key, "boom_freq_end") == 0) v->gun_boom_freq_end = value; 882 + else if (strcmp(key, "boom_pitch_decay_ms") == 0) { 883 + double tau = (value > 0.1 ? value : 0.1) * 0.001; 884 + v->gun_boom_pitch_mult = exp(-1.0 / (tau * sr)); 885 + } 886 + else if (strcmp(key, "boom_amp_decay_ms") == 0) { 887 + double tau = (value > 0.1 ? value : 0.1) * 0.001; 888 + v->gun_boom_decay_mult = exp(-1.0 / (tau * sr)); 889 + } 890 + else if (strcmp(key, "tail_amp") == 0) v->gun_body_amp[2] = value; 891 + else if (strcmp(key, "tail_decay_ms") == 0) { 892 + double tau = (value > 0.1 ? value : 0.1) * 0.001; 893 + v->gun_tail_decay_mult = exp(-1.0 / (tau * sr)); 894 + } 895 + else if (strcmp(key, "tail_fc") == 0 || strcmp(key, "tail_q") == 0) { 896 + double a2 = v->gun_body_a2[1]; 897 + double r = a2 > 0 ? sqrt(a2) : 0.95; 898 + double cur_w = acos(v->gun_body_a1[1] / (2.0 * r)); 899 + double cur_f = cur_w * sr / (2.0 * M_PI); 900 + double cur_q = -M_PI * cur_f / (sr * log(r > 0.0001 ? r : 0.0001)); 901 + double f = (strcmp(key, "tail_fc") == 0) ? value : cur_f; 902 + double q = (strcmp(key, "tail_q") == 0) ? value : cur_q; 903 + compute_resonator(f, q, sr, &v->gun_body_a1[1], &v->gun_body_a2[1], &v->gun_tail_b0); 904 + } 905 + } else { 906 + if (strcmp(key, "pressure") == 0) v->gun_pressure = value; 907 + else if (strcmp(key, "env_rate") == 0) { 908 + v->gun_phys_t_plus = (3.0 / (value > 100 ? value : 100.0)) * sr; 909 + if (v->gun_phys_t_plus < 32.0) v->gun_phys_t_plus = 32.0; 910 + if (v->gun_phys_t_plus > 4096.0) v->gun_phys_t_plus = 4096.0; 911 + } 912 + else if (strcmp(key, "bore_length_s") == 0) { 913 + v->gun_bore_delay = value * sr; 914 + if (v->gun_bore_delay < 4.0) v->gun_bore_delay = 4.0; 915 + if (v->gun_bore_delay > 2040.0) v->gun_bore_delay = 2040.0; 916 + } 917 + else if (strcmp(key, "bore_loss") == 0) v->gun_bore_loss = value; 918 + else if (strcmp(key, "breech_reflect") == 0) v->gun_breech_reflect = value; 919 + else if (strcmp(key, "noise_gain") == 0) v->gun_noise_gain = value; 920 + else if (strcmp(key, "radiation") == 0) v->gun_radiation_a = value; 921 + else if (strncmp(key, "body_freq", 9) == 0 || strncmp(key, "body_q", 6) == 0) { 922 + int idx = key[strlen(key) - 1] - '0'; 923 + if (idx >= 0 && idx <= 2) { 924 + double a2 = v->gun_body_a2[idx]; 925 + double r = a2 > 0 ? sqrt(a2) : 0.95; 926 + double cur_w = acos(v->gun_body_a1[idx] / (2.0 * r)); 927 + double cur_f = cur_w * sr / (2.0 * M_PI); 928 + double cur_q = -M_PI * cur_f / (sr * log(r > 0.0001 ? r : 0.0001)); 929 + double f = (strncmp(key, "body_freq", 9) == 0) ? value : cur_f; 930 + double q = (strncmp(key, "body_q", 6) == 0) ? value : cur_q; 931 + double b0_unused; 932 + compute_resonator(f, q, sr, &v->gun_body_a1[idx], &v->gun_body_a2[idx], &b0_unused); 933 + } 934 + } 935 + else if (strncmp(key, "body_amp", 8) == 0) { 936 + int idx = key[strlen(key) - 1] - '0'; 937 + if (idx >= 0 && idx <= 2) v->gun_body_amp[idx] = value; 938 + } 939 + } 940 + pthread_mutex_unlock(s->lock); 941 + } 942 + 943 + // ─── Render loop ──────────────────────────────────────────────────────────── 944 + 945 + void synth_render(SynthCore *s, float *out, int frames) { 946 + if (!s || frames <= 0 || !out) return; 947 + pthread_mutex_lock(s->lock); 948 + double sr = s->sample_rate; 949 + double dt = 1.0 / sr; 950 + for (int f = 0; f < frames; f++) { 951 + double l = 0.0, r = 0.0; 952 + for (int i = 0; i < s->max_voices; i++) { 953 + ACVoice *v = &s->voices[i]; 954 + if (v->state == VOICE_INACTIVE) continue; 955 + double samp = synth_generate_sample(v, sr); 956 + double env = synth_compute_envelope(v); 957 + double fade = synth_compute_fade(v); 958 + double amp = samp * env * fade * v->volume; 959 + double theta = (v->pan + 1.0) * (M_PI / 4.0); 960 + l += amp * cos(theta); 961 + r += amp * sin(theta); 962 + v->elapsed += dt; 963 + if (v->state == VOICE_KILLING) { 964 + v->fade_elapsed += dt; 965 + if (v->fade_elapsed >= v->fade_duration) v->state = VOICE_INACTIVE; 966 + } else if (!isinf(v->duration) && v->elapsed >= v->duration) { 967 + v->state = VOICE_INACTIVE; 968 + } 969 + } 970 + out[f * 2 + 0] += (float)l; 971 + out[f * 2 + 1] += (float)r; 972 + } 973 + pthread_mutex_unlock(s->lock); 974 + }
+69
fedac/native/src/synth_core.h
··· 1 + // synth_core.h — Platform-agnostic AC synth engine. 2 + // Holds oscillators (sine/triangle/sawtooth/square/noise/whistle/gun), 3 + // gun presets + init, envelope + fade math, note-to-freq lookup, and 4 + // voice lifecycle helpers operating on an externally-owned voice array. 5 + // 6 + // Consumers: 7 + // - Linux ALSA driver (fedac/native/src/audio.c) — work-in-progress 8 + // migration; for now this file can co-exist with audio.c's copies. 9 + // - macOS SDL3 driver (fedac/native/macos/audio.c) — uses this directly. 10 + #ifndef AC_SYNTH_CORE_H 11 + #define AC_SYNTH_CORE_H 12 + 13 + #include "synth_types.h" 14 + #include <pthread.h> 15 + 16 + // Externally-owned voice pool the synth operates on. The driver allocates 17 + // the voice array and the mutex; the synth reads/writes under the lock. 18 + typedef struct { 19 + ACVoice *voices; 20 + int max_voices; 21 + pthread_mutex_t *lock; 22 + uint64_t *next_id; 23 + double sample_rate; 24 + } SynthCore; 25 + 26 + void synth_core_init(SynthCore *s, 27 + ACVoice *voices, int max_voices, 28 + pthread_mutex_t *lock, uint64_t *next_id, 29 + double sample_rate); 30 + 31 + // Allocate a standard oscillator voice. Returns new id, or 0 on failure. 32 + // `duration` may be INFINITY for sustained voices (held until synth_kill). 33 + uint64_t synth_synth(SynthCore *s, WaveType type, double freq, 34 + double duration, double volume, double attack, 35 + double decay, double pan); 36 + 37 + // Allocate a gun voice. `force_model` -1 = use preset default, 0 = classic, 38 + // 1 = physical. `pressure_scale` scales gun_pressure (1.0 = preset default). 39 + uint64_t synth_synth_gun(SynthCore *s, GunPreset preset, double duration, 40 + double volume, double attack, double decay, 41 + double pan, double pressure_scale, int force_model); 42 + 43 + void synth_kill (SynthCore *s, uint64_t id, double fade); 44 + void synth_update(SynthCore *s, uint64_t id, double freq, double vol, double pan); 45 + void synth_gun_set_param(SynthCore *s, uint64_t id, const char *key, double val); 46 + 47 + // Pull N stereo float32 frames into `out` (L/R interleaved). Accumulates — 48 + // pre-zero out[] if you want a clean buffer. Advances voice elapsed/phase 49 + // each sample, retires voices that finish (duration expired or kill-fade 50 + // completed). Thread-safe: takes the pool lock internally. 51 + // 52 + // `dt_advance` controls whether elapsed time advances per-sample (true for 53 + // realtime rendering). Pass false only when pre-rendering. 54 + void synth_render(SynthCore *s, float *out, int frames); 55 + 56 + // Utilities. 57 + double synth_note_to_freq(const char *note); 58 + WaveType synth_parse_wave(const char *s); 59 + 60 + // Low-level per-voice APIs (driver owns the loop / mixing). 61 + double synth_generate_sample(ACVoice *v, double sample_rate); 62 + double synth_compute_envelope(const ACVoice *v); 63 + double synth_compute_fade(const ACVoice *v); 64 + 65 + // Precompute the noise biquad LPF coefficients from v->frequency. 66 + // Call after setting v->frequency on a WAVE_NOISE voice. 67 + void synth_setup_noise_filter(ACVoice *v, double sample_rate); 68 + 69 + #endif
+147
fedac/native/src/synth_types.h
··· 1 + // synth_types.h — Shared voice/wave/gun types for the AC synth. 2 + // Included by both the Linux ALSA driver (audio.h) and the macOS SDL3 3 + // driver (macos/audio.c) so voice layout never diverges. Intentionally 4 + // free of pthread / audio-decode / ffmpeg deps; only <stdint.h>. 5 + #ifndef AC_SYNTH_TYPES_H 6 + #define AC_SYNTH_TYPES_H 7 + 8 + #include <stdint.h> 9 + 10 + #define AUDIO_SAMPLE_RATE 192000 11 + #define AUDIO_CHANNELS 2 12 + #define AUDIO_PERIOD_SIZE 192 // ~1ms at 192kHz — minimal latency 13 + #define AUDIO_MAX_VOICES 32 14 + #define AUDIO_WAVEFORM_SIZE 512 15 + #define AUDIO_MAX_SAMPLE_VOICES 12 16 + 17 + typedef enum { 18 + VOICE_INACTIVE = 0, 19 + VOICE_ACTIVE, 20 + VOICE_KILLING 21 + } VoiceState; 22 + 23 + typedef enum { 24 + WAVE_SINE = 0, 25 + WAVE_TRIANGLE, 26 + WAVE_SAWTOOTH, 27 + WAVE_SQUARE, 28 + WAVE_NOISE, 29 + WAVE_WHISTLE, 30 + WAVE_GUN, 31 + WAVE_HARP 32 + } WaveType; 33 + 34 + typedef enum { 35 + GUN_MODEL_CLASSIC = 0, 36 + GUN_MODEL_PHYSICAL = 1 37 + } GunModel; 38 + 39 + typedef enum { 40 + GUN_PISTOL = 0, GUN_RIFLE, GUN_SHOTGUN, GUN_SMG, GUN_SUPPRESSED, 41 + GUN_LMG, GUN_SNIPER, GUN_GRENADE, GUN_RPG, GUN_RELOAD, GUN_COCK, 42 + GUN_RICOCHET, GUN_PRESET_COUNT 43 + } GunPreset; 44 + 45 + // Per-voice state. Layout must match src/audio.h ACVoice exactly while that 46 + // file still declares its own copy — both sides include this header now, 47 + // but for safety during the migration the struct lives here and audio.h 48 + // re-exports it via typedef alias. 49 + typedef struct { 50 + VoiceState state; 51 + WaveType type; 52 + double phase; 53 + double frequency; 54 + double target_frequency; 55 + double volume; 56 + double pan; 57 + double attack; 58 + double decay; 59 + double duration; 60 + double elapsed; 61 + double fade_duration; 62 + double fade_elapsed; 63 + double started_at; 64 + uint64_t id; 65 + // Noise filter state (biquad LPF applied to xorshift32 output). 66 + double noise_b0, noise_b1, noise_b2, noise_a1, noise_a2; 67 + double noise_x1, noise_x2, noise_y1, noise_y2; 68 + uint32_t noise_seed; 69 + // Whistle (Perry Cook STK flute): bore delay + jet delay + cubic NL. 70 + double whistle_breath; 71 + double whistle_vibrato_phase; 72 + double whistle_lp1; 73 + double whistle_hp_x1, whistle_hp_y1; 74 + float whistle_bore_buf[2048]; 75 + int whistle_bore_w; 76 + float whistle_jet_buf[512]; 77 + int whistle_jet_w; 78 + // Gun (shared fields between classic & physical). 79 + int gun_preset; 80 + double gun_bore_delay; 81 + double gun_bore_loss; 82 + double gun_bore_lp; 83 + double gun_breech_reflect; 84 + double gun_pressure; 85 + double gun_pressure_env; 86 + double gun_env_decay_mult; 87 + double gun_noise_gain; 88 + double gun_radiation_a; 89 + double gun_rad_prev; 90 + double gun_secondary_trig; 91 + double gun_secondary_amp; 92 + int gun_sustain_fire; 93 + double gun_retrig_timer; 94 + double gun_retrig_period; 95 + double gun_body_a1[3], gun_body_a2[3]; 96 + double gun_body_amp[3]; 97 + double gun_body_y1[3], gun_body_y2[3]; 98 + double gun_pitch_mult; 99 + double gun_pitch_target; 100 + double gun_pitch_slew; 101 + // Gun classic layers. 102 + int gun_model; 103 + double gun_boom_phase; 104 + double gun_boom_freq; 105 + double gun_boom_freq_start; 106 + double gun_boom_freq_end; 107 + double gun_boom_pitch_mult; 108 + double gun_boom_env; 109 + double gun_boom_decay_mult; 110 + double gun_tail_env; 111 + double gun_tail_attack_inc; 112 + double gun_tail_decay_mult; 113 + double gun_crack_b0; 114 + double gun_tail_b0, gun_tail_b1, gun_tail_b2; 115 + double gun_click_env; 116 + double gun_click_decay_mult; 117 + double gun_click_amp; 118 + double gun_click_prev; 119 + // Gun physical Friedlander pulse. 120 + double gun_phys_t; 121 + double gun_phys_t_plus; 122 + double gun_phys_friedlander_a; 123 + double gun_phys_neg_amp; 124 + double gun_phys_echo_delay; 125 + double gun_phys_echo_amp; 126 + double gun_phys_echo_buf[1024]; 127 + int gun_phys_echo_w; 128 + // Harp — Karplus-Strong plucked string. Reuses whistle_bore_buf / 129 + // whistle_bore_w as the string delay line (voice can only be one 130 + // wave type at a time). `harp_lp1` is the two-point moving-average 131 + // damping filter state from Karplus & Strong (CMJ 7:2, 1983). 132 + double harp_lp1; 133 + } ACVoice; 134 + 135 + typedef struct { 136 + int active; 137 + int loop; 138 + double position; 139 + double speed; 140 + double volume; 141 + double pan; 142 + double fade; 143 + double fade_target; 144 + uint64_t id; 145 + } SampleVoice; 146 + 147 + #endif
+6
fedac/native/test-pieces/gun.mjs
··· 1 + // gun.mjs — exercises the gun-preset path in synth_core. 2 + // Headless run: AC_HEADLESS_MS=1500 ./ac-native-macos test-pieces/gun.mjs 3 + export function boot({ sound }) { 4 + sound.synth({ type: "gun:pistol", duration: 0.5, volume: 0.6 }); 5 + } 6 + export function paint({ wipe }) { wipe(0); }
+18
fedac/native/test-pieces/tone.mjs
··· 1 + // tone.mjs — smoke test for the JS → C audio bridge. 2 + // Plays A4 (440 Hz) sine for 1s on boot. Intended for headless/CI use: 3 + // AC_HEADLESS_MS=1500 ./ac-native-macos test-pieces/tone.mjs 4 + // A non-zero peak in [audio] stop proves the bridge is working. 5 + 6 + export function boot({ sound }) { 7 + sound.synth({ 8 + type: "sine", 9 + tone: 440, 10 + duration: 1.0, 11 + volume: 0.3, 12 + attack: 0.01, 13 + decay: 0.1, 14 + pan: 0, 15 + }); 16 + } 17 + 18 + export function paint({ wipe }) { wipe(0, 0, 0); }
+7
fedac/native/test-pieces/whistle.mjs
··· 1 + // whistle.mjs — exercises the WAVE_WHISTLE path in synth_core. 2 + // Headless run: AC_HEADLESS_MS=2000 ./ac-native-macos test-pieces/whistle.mjs 3 + export function boot({ sound }) { 4 + sound.synth({ type: "whistle", tone: 440, duration: 1.5, volume: 0.4, 5 + attack: 0.05, decay: 0.2 }); 6 + } 7 + export function paint({ wipe }) { wipe(0); }