From 04111073c7a43f35eb0d041d9544c97d9f043ec7 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 20 Apr 2026 12:39:53 -0700 Subject: [PATCH] fedac/native/macos: drop Notepat Grand; chromeless 320x240@d2 default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grand is gone — remove audio_musicdevice.c, the AUDIO=grand case, the grand/grand-install Make targets, and the now-dead #ifdef AC_GRAND in main.c. AUDIO is back to {core, sdl} (A/B latency comparison). Notepat now opens a borderless window at 640×480 points / density 2 (320×240 framebuffer) so the piece can draw its own chrome on retro- resolution pixels. Cmd+drag still moves the window via the existing hit test, now applied to every non-fullscreen window instead of only the overlay variant. Co-Authored-By: Claude Opus 4.7 (1M context) --- fedac/native/macos/Makefile | 34 +-- fedac/native/macos/audio_musicdevice.c | 305 ------------------------- fedac/native/macos/main.c | 41 ++-- 3 files changed, 29 insertions(+), 351 deletions(-) delete mode 100644 fedac/native/macos/audio_musicdevice.c diff --git a/fedac/native/macos/Makefile b/fedac/native/macos/Makefile index 105013c91..81767c5a6 100644 --- a/fedac/native/macos/Makefile +++ b/fedac/native/macos/Makefile @@ -6,9 +6,7 @@ CC ?= clang BUILD := build # Target name carries the audio backend so switching AUDIO= between builds -# doesn't silently leave a stale binary behind (e.g. packaging the grand -# binary under the default Notepat name because $(TARGET) was already newer -# than its deps from a prior AUDIO=grand run). +# (e.g. core ↔ sdl for latency A/B) doesn't leave a stale binary behind. AUDIO ?= core TARGET := $(BUILD)/ac-native-macos-$(AUDIO) @@ -23,23 +21,18 @@ CFLAGS := -O2 -Wall -Wextra -std=gnu11 -I. -I../src -I$(QJSDIR) $(SDL3_CFLAGS) LDFLAGS := -lm -framework Carbon # Audio backend: AUDIO=core (default) uses a direct CoreAudio AudioUnit -# (custom synth_core oscillators); AUDIO=grand swaps in Apple's AUMIDISynth -# for sampled Acoustic Grand Piano; AUDIO=sdl uses SDL3's audio stream for -# A/B latency tests. All three implement macos/audio.h so the SDL3 host -# and notepat piece don't need to know which backend is live. +# running the custom synth_core oscillators; AUDIO=sdl routes through SDL3's +# audio stream so we can A/B the two latency profiles. Both implement +# macos/audio.h so main.c + piece.c don't care which is live. ifeq ($(AUDIO),core) AUDIO_SRC := audio_coreaudio.c AUDIO_FRAMEWORKS := -framework AudioUnit -framework AudioToolbox \ -framework CoreAudio -framework CoreFoundation -else ifeq ($(AUDIO),grand) - AUDIO_SRC := audio_musicdevice.c - AUDIO_FRAMEWORKS := -framework AudioUnit -framework AudioToolbox \ - -framework CoreAudio -framework CoreFoundation else ifeq ($(AUDIO),sdl) AUDIO_SRC := audio_sdl3.c AUDIO_FRAMEWORKS := else - $(error AUDIO must be 'core', 'grand', or 'sdl') + $(error AUDIO must be 'core' or 'sdl') endif HOST_SRCS := main.c piece.c $(AUDIO_SRC) @@ -52,13 +45,12 @@ LDFLAGS += $(SDL3_LIBS_BASE) $(AUDIO_FRAMEWORKS) QJS_SRCS := quickjs.c libunicode.c libregexp.c cutils.c libbf.c QJS_OBJS := $(patsubst %.c,$(BUILD)/qjs-%.o,$(QJS_SRCS)) -.PHONY: all clean run app app-run compare install uninstall grand grand-install +.PHONY: all clean run app app-run compare install uninstall all: $(TARGET) -# Paths for the .app bundle. -# APP_NAME / APP_ID override the label + CFBundleIdentifier, so a second -# bundle (e.g. "Notepat Grand") can share the same sources. +# Paths for the .app bundle. APP_NAME / APP_ID can still be overridden if +# we spin up a second bundle later — just keep the CFBundleIdentifier unique. APP_NAME ?= Notepat APP_ID ?= computer.aesthetic.notepat APP_DIR := $(BUILD)/$(APP_NAME).app @@ -186,16 +178,6 @@ uninstall: rm -rf "$(INSTALL_DIR)/$(APP_NAME).app" @echo "[uninstall] removed $(INSTALL_DIR)/$(APP_NAME).app" -# "Notepat Grand" — same SDL3 host + notepat grid UI as the default build, -# but swaps the CoreAudio oscillator engine for Apple's AUMIDISynth so every -# key plays a sampled Acoustic Grand Piano voice instead. Separate bundle ID -# so both versions can coexist in /Applications. -grand: - $(MAKE) AUDIO=grand APP_NAME="Notepat Grand" APP_ID=computer.aesthetic.notepat.grand app - -grand-install: - $(MAKE) AUDIO=grand APP_NAME="Notepat Grand" APP_ID=computer.aesthetic.notepat.grand install - clean: rm -rf $(BUILD) diff --git a/fedac/native/macos/audio_musicdevice.c b/fedac/native/macos/audio_musicdevice.c deleted file mode 100644 index b4cc90dc1..000000000 --- a/fedac/native/macos/audio_musicdevice.c +++ /dev/null @@ -1,305 +0,0 @@ -// audio_musicdevice.c — AUMIDISynth-backed audio backend for "Notepat Grand". -// Uses Apple's kAudioUnitSubType_MIDISynth which ships with the default -// General MIDI soundbank (gs_instruments.dls). Program 0 = Acoustic Grand -// Piano — same engine GarageBand/Logic use for quick MIDI playback before -// swapping to their proprietary sample libraries. No asset bundling. -// -// Mapping: sound.synth({tone, volume, duration, ...}) → NoteOn; kill() or -// duration expiry → NoteOff. Tone (Hz) → nearest integer MIDI note; volume -// (0..1) → MIDI velocity. Polyphonic by default (MIDI handles voice mgmt). - -#include "audio.h" -#include "synth_core.h" - -#include -#include -#include - -#include -#include -#include -#include -#include -#include - -#define MIDI_CHANNEL 0 -#define DEFAULT_GM_PROGRAM 0 /* Acoustic Grand Piano */ - -static uint64_t now_ns(void) { - struct timespec ts; - clock_gettime(CLOCK_MONOTONIC, &ts); - return (uint64_t)ts.tv_sec * 1000000000ULL + ts.tv_nsec; -} - -// Per-voice slot that tracks which MIDI note is currently sounding so kill() -// can turn the right one off. Matches synth_types' AUDIO_MAX_VOICES. -typedef struct { - int active; - uint64_t id; - int note; // MIDI note number - // If duration was finite, a detached thread schedules the NoteOff. - // The flag below lets kill() / process-shutdown pre-empt the timer. - int duration_fired; -} GrandVoice; - -struct Audio { - AUGraph graph; - AUNode synth_node, out_node; - AudioUnit synth_au, out_au; - pthread_mutex_t lock; - GrandVoice voices[AUDIO_MAX_VOICES]; - uint64_t next_id; - // Still honor the latency instrumentation surface so AC_LATENCY_TEST - // works against this backend too, even though the timing chain is - // different (MIDI event → AU render). - volatile uint64_t trigger_ns; - volatile uint64_t emit_ns; - volatile float emit_threshold; // unused; emit is stamped at NoteOn - volatile uint64_t samples_out; - volatile float peak_out; -}; - -// We don't own the render callback (MIDISynth does), so latency is stamped -// at NoteOn dispatch — pessimistic upper bound relative to SDL3/CoreAudio -// direct (which stamps in the audio callback). Worst-case the difference is -// the AU's internal buffer period (~1–3 ms on CoreAudio default). - -// ── Helpers ──────────────────────────────────────────────────────────────── - -static int hz_to_midi(double hz) { - if (hz < 8.0) hz = 8.0; - int note = (int)lround(12.0 * log2(hz / 440.0) + 69.0); - if (note < 0) note = 0; - if (note > 127) note = 127; - return note; -} - -static int vol_to_velocity(double v) { - // notepat passes most note volumes in the 0.3–0.6 range. Raw mapping - // (v*127) would give velocity 38–76, which on a piano bank reads as - // "barely touching the key." Boost so a typical hit lands around the - // 90–120 range where the bank's loudest samples get selected. - if (v < 0) v = 0; - double boosted = v * 180.0; - int vel = (int)lround(boosted); - if (vel < 1) vel = 1; - if (vel > 127) vel = 127; - return vel; -} - -static void send_note_on(Audio *a, int note, int velocity) { - MusicDeviceMIDIEvent(a->synth_au, 0x90 | MIDI_CHANNEL, note, velocity, 0); -} -static void send_note_off(Audio *a, int note) { - MusicDeviceMIDIEvent(a->synth_au, 0x80 | MIDI_CHANNEL, note, 0, 0); -} -static void send_program_change(Audio *a, int program) { - MusicDeviceMIDIEvent(a->synth_au, 0xC0 | MIDI_CHANNEL, program & 0x7F, 0, 0); -} - -// ── Duration timer ───────────────────────────────────────────────────────── -// Finite-duration notes need a NoteOff scheduled at `duration` seconds from -// NoteOn. MusicDeviceMIDIEvent with `inOffsetSampleFrame` only schedules -// within a single render cycle, so for arbitrary durations we use detached -// threads. Light enough for notepat's voice counts (max 32 active). Each -// thread captures its slot index; the lock guards concurrent access. - -typedef struct { - Audio *a; - int slot; - uint64_t id; - double duration_s; -} TimerArg; - -static void *timer_off_thread(void *arg) { - TimerArg *t = (TimerArg *)arg; - Audio *a = t->a; - int slot = t->slot; - uint64_t id = t->id; - double s = t->duration_s; - free(t); - - struct timespec ts; - ts.tv_sec = (time_t)s; - ts.tv_nsec = (long)((s - (double)ts.tv_sec) * 1e9); - nanosleep(&ts, NULL); - - pthread_mutex_lock(&a->lock); - if (a->voices[slot].active && a->voices[slot].id == id && !a->voices[slot].duration_fired) { - send_note_off(a, a->voices[slot].note); - a->voices[slot].active = 0; - a->voices[slot].duration_fired = 1; - } - pthread_mutex_unlock(&a->lock); - return NULL; -} - -// ── Public API ────────────────────────────────────────────────────────────── - -Audio *audio_init(void) { - Audio *a = (Audio *)calloc(1, sizeof(Audio)); - if (!a) return NULL; - pthread_mutex_init(&a->lock, NULL); - a->next_id = 0; - - if (NewAUGraph(&a->graph) != noErr) { free(a); return NULL; } - AudioComponentDescription synthDesc = { - .componentType = kAudioUnitType_MusicDevice, - .componentSubType = kAudioUnitSubType_MIDISynth, - .componentManufacturer = kAudioUnitManufacturer_Apple, - }; - AudioComponentDescription outDesc = { - .componentType = kAudioUnitType_Output, - .componentSubType = kAudioUnitSubType_DefaultOutput, - .componentManufacturer = kAudioUnitManufacturer_Apple, - }; - if (AUGraphAddNode(a->graph, &synthDesc, &a->synth_node) != noErr - || AUGraphAddNode(a->graph, &outDesc, &a->out_node) != noErr - || AUGraphOpen(a->graph) != noErr - || AUGraphNodeInfo(a->graph, a->synth_node, NULL, &a->synth_au) != noErr - || AUGraphNodeInfo(a->graph, a->out_node, NULL, &a->out_au) != noErr - || AUGraphConnectNodeInput(a->graph, a->synth_node, 0, a->out_node, 0) != noErr) { - fprintf(stderr, "[audio] AUGraph construction failed\n"); - DisposeAUGraph(a->graph); - free(a); - return NULL; - } - - // Stream-from-disk ON so we don't wait for the full bank to preload. - UInt32 enable = 1; - AudioUnitSetProperty(a->synth_au, kMusicDeviceProperty_StreamFromDisk, - kAudioUnitScope_Input, 0, &enable, sizeof(enable)); - - if (AUGraphInitialize(a->graph) != noErr) { - fprintf(stderr, "[audio] AUGraphInitialize failed\n"); - DisposeAUGraph(a->graph); - free(a); - return NULL; - } - if (AUGraphStart(a->graph) != noErr) { - fprintf(stderr, "[audio] AUGraphStart failed\n"); - AUGraphUninitialize(a->graph); - DisposeAUGraph(a->graph); - free(a); - return NULL; - } - - int program = DEFAULT_GM_PROGRAM; - const char *env = getenv("AC_GM_PROGRAM"); - if (env) program = atoi(env); - send_program_change(a, program); - - fprintf(stderr, "[audio] AUMIDISynth ready (GM program %d = %s)\n", - program, program == 0 ? "Acoustic Grand Piano" : "custom"); - return a; -} - -void audio_destroy(Audio *a) { - if (!a) return; - // All-notes-off so the synth doesn't dangle held voices. - for (int ch = 0; ch < 16; ch++) { - MusicDeviceMIDIEvent(a->synth_au, 0xB0 | ch, 123, 0, 0); // CC#123 all notes off - } - AUGraphStop(a->graph); - AUGraphUninitialize(a->graph); - DisposeAUGraph(a->graph); - pthread_mutex_destroy(&a->lock); - fprintf(stderr, "[audio] stop: MIDISynth\n"); - free(a); -} - -uint64_t audio_synth(Audio *a, WaveType w, double freq, double dur, double vol, - double att, double dec, double pan) { - (void)w; (void)att; (void)dec; (void)pan; // MIDI piano ignores oscillator shape + envelope shape - if (!a) return 0; - - pthread_mutex_lock(&a->lock); - int slot = -1; - for (int i = 0; i < AUDIO_MAX_VOICES; i++) { - if (!a->voices[i].active) { slot = i; break; } - } - if (slot < 0) slot = 0; // voice theft: reuse slot 0 - // If stealing, mute that slot's current note first. - if (a->voices[slot].active) { - send_note_off(a, a->voices[slot].note); - } - - int note = hz_to_midi(freq); - int vel = vol_to_velocity(vol); - - uint64_t id = ++a->next_id; - a->voices[slot].active = 1; - a->voices[slot].id = id; - a->voices[slot].note = note; - a->voices[slot].duration_fired = 0; - - if (a->trigger_ns && !a->emit_ns) a->emit_ns = now_ns(); - - send_note_on(a, note, vel); - - // Finite duration → detached timer thread that fires NoteOff. - // INFINITY or <= 0 stays sustained until kill(). - if (dur > 0 && isfinite(dur)) { - TimerArg *t = (TimerArg *)malloc(sizeof(TimerArg)); - if (t) { - t->a = a; t->slot = slot; t->id = id; t->duration_s = dur; - pthread_t th; - pthread_attr_t attr; - pthread_attr_init(&attr); - pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED); - pthread_create(&th, &attr, timer_off_thread, t); - pthread_attr_destroy(&attr); - } - } - - pthread_mutex_unlock(&a->lock); - return id; -} - -uint64_t audio_synth_gun(Audio *a, GunPreset preset, double duration, - double volume, double attack, double decay, - double pan, double pressure_scale, int force_model) { - (void)preset; (void)attack; (void)decay; (void)pan; - (void)pressure_scale; (void)force_model; - // Route gun-preset triggers to percussion notes (GM channel 10 has its - // own drum map). For simplicity map all to the current program's kick- - // drum equivalent (C2 = MIDI 36) at the requested volume. - return audio_synth(a, WAVE_NOISE, 65.41, duration, volume, 0.0, 0.0, 0.0); -} - -void audio_kill(Audio *a, uint64_t id, double fade) { - (void)fade; // MIDI NoteOff is immediate; release is baked into the AU patch - if (!a || !id) return; - pthread_mutex_lock(&a->lock); - for (int i = 0; i < AUDIO_MAX_VOICES; i++) { - if (a->voices[i].active && a->voices[i].id == id) { - send_note_off(a, a->voices[i].note); - a->voices[i].active = 0; - a->voices[i].duration_fired = 1; - break; - } - } - pthread_mutex_unlock(&a->lock); -} - -void audio_update(Audio *a, uint64_t id, double freq, double vol, double pan) { - (void)a; (void)id; (void)freq; (void)vol; (void)pan; - // Update is a no-op in MIDI-land — pitch/vel fixed at NoteOn for piano. -} - -void audio_gun_set_param(Audio *a, uint64_t id, const char *key, double value) { - (void)a; (void)id; (void)key; (void)value; // n/a for MIDI backend -} - -WaveType audio_parse_wave(const char *s) { return synth_parse_wave(s); } - -void audio_arm_latency(Audio *a, float threshold) { - if (!a) return; - a->emit_ns = 0; - a->emit_threshold = threshold > 0.0f ? threshold : 0.02f; - a->trigger_ns = now_ns(); -} -uint64_t audio_latency_ns(Audio *a) { - if (!a || !a->trigger_ns || !a->emit_ns) return 0; - return a->emit_ns - a->trigger_ns; -} diff --git a/fedac/native/macos/main.c b/fedac/native/macos/main.c index f6f4d7f32..f6ba710aa 100644 --- a/fedac/native/macos/main.c +++ b/fedac/native/macos/main.c @@ -16,12 +16,13 @@ #include "piece.h" #include "audio.h" -// Initial window size in logical points — what you'd expect for a -// non-retina display. The actual framebuffer is computed from the window's -// pixel size divided by DENSITY so it reflows on resize without letterbox. -#define INITIAL_WIN_W 1280 -#define INITIAL_WIN_H 800 -#define DEFAULT_DENSITY 1 // logical points per framebuffer pixel — 1 = web AC parity (FB matches window points, notepat's top bar has room); higher = chunkier +// Initial window size in logical points. The framebuffer is win / DENSITY, +// so 640×480 @ d=2 yields a 320×240 canvas — a classic retro resolution +// rendered chunky 2× on-screen (and 4× physical on retina thanks to +// HIGH_PIXEL_DENSITY + nearest-neighbor). +#define INITIAL_WIN_W 640 +#define INITIAL_WIN_H 480 +#define DEFAULT_DENSITY 2 // Shared state the event watch callback needs. SDL calls the watch on the // same thread as SDL_PollEvent, during the OS resize modal run loop, so @@ -141,10 +142,11 @@ static const char *detect_bundle(char *piece_buf, size_t piece_sz, return NULL; } -// ── Overlay mode helpers ──────────────────────────────────────────────────── +// ── Chromeless window helpers ─────────────────────────────────────────────── -// Cmd-drag moves the borderless overlay window. Hit test runs every mouse -// move to decide whether the click belongs to the app or the window manager. +// Cmd-drag moves the borderless window (default + overlay both run chromeless +// so the piece draws its own titlebar). Hit test runs every mouse move to +// decide whether the click belongs to the app or the window manager. static SDL_HitTestResult SDLCALL hit_test_cmd_drag(SDL_Window *win, const SDL_Point *pt, void *data) { @@ -276,22 +278,21 @@ int main(int argc, char **argv) { // reads as blurry. With it on, nearest-neighbor stays nearest-neighbor // all the way through to the pixel. int fullscreen = getenv("AC_FULLSCREEN") != NULL; - // Grand build defaults overlay on; AC_OVERLAY=0 forces it off. + // Transparent-HUD mode is opt-in via AC_OVERLAY=1. int overlay = 0; -#ifdef AC_GRAND - overlay = 1; -#endif const char *ov_env = getenv("AC_OVERLAY"); if (ov_env) overlay = (atoi(ov_env) != 0); - Uint32 win_flags = SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIGH_PIXEL_DENSITY; + // Borderless by default — notepat draws its own chrome, so we skip the + // stock macOS titlebar + traffic lights. Cmd+drag still moves the window + // via the hit test (applied to every non-fullscreen window below). + Uint32 win_flags = SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIGH_PIXEL_DENSITY + | SDL_WINDOW_BORDERLESS; if (fullscreen) win_flags |= SDL_WINDOW_FULLSCREEN; if (overlay) { - // Borderless + transparent so the piece can draw on the desktop; - // always-on-top pins it above other windows (HUD feel). Cmd+drag - // to reposition — the hit test below routes those clicks to the - // window server rather than the piece. + // Transparent + always-on-top on top of the borderless base turns the + // piece into a HUD that floats over other windows while remaining + // Cmd-draggable. win_flags |= SDL_WINDOW_TRANSPARENT - | SDL_WINDOW_BORDERLESS | SDL_WINDOW_ALWAYS_ON_TOP; } @@ -299,7 +300,7 @@ int main(int argc, char **argv) { INITIAL_WIN_W, INITIAL_WIN_H, win_flags); if (!win) { fprintf(stderr, "SDL_CreateWindow: %s\n", SDL_GetError()); SDL_Quit(); return 1; } - if (overlay) SDL_SetWindowHitTest(win, hit_test_cmd_drag, NULL); + if (!fullscreen) SDL_SetWindowHitTest(win, hit_test_cmd_drag, NULL); SDL_Renderer *ren = SDL_CreateRenderer(win, NULL); if (!ren) { fprintf(stderr, "SDL_CreateRenderer: %s\n", SDL_GetError()); SDL_Quit(); return 1; } -- 2.51.2