feat: true no-X headless --render (CPU spectrogram bitmap + overlay)

Rewrite --render to compute the spectrogram and write a PNG entirely on the
CPU, with no window, no GL context, and no X server. Previously it opened a
hidden GL window and grabbed LoadImageFromScreen(), which still required an X
server (Xvfb); the output was a UI screenshot rather than the spectrogram data.

The new path (RunHeadlessRender) loads the WAV, computes the STFT, colorizes
the bitmap at native STFT resolution, bakes the mLnL annotation overlay onto
it, and exports — all CPU-only. render.c gains a GL-free colorize
(BuildSpectrogramImageCPU), a CPU font loader (LoadFontCPU), and a CPU overlay
drawer (DrawAnnotationsToImage).

Annotations draw outline + label only: mLnL captures contain many overlapping
full-band boxes whose translucent fills alpha-stack to opaque and bury the
signal. The outline marks each region while the spectrogram reads through; a
dark backing strip keeps labels legible. Note: MeasureTextEx/ImageText* bail
when font.texture.id == 0, so the CPU font sets a sentinel non-zero id (the
draw path reads glyph images, never the texture).

Render options: --annotation-opacity (overlay strength), --annotation-kinds
(comma-separated kind filter), --width (resize; default native). Removed the
obsolete --pane/--height window options and the screenshot workaround.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 22:37:38 -07:00
parent 95be6f6c22
commit fb7bc5486e
5 changed files with 378 additions and 120 deletions
+174 -106
View File
@@ -531,6 +531,143 @@ static void DispatchKeymap(void)
}
}
// ============================================================================
// Headless render (no window, no GL, no X server)
// ============================================================================
// Resolve an mLnL kind token (as emitted by MlnlKindName, plus "unknown") to
// its enum value, or -1 if unrecognized.
static int KindFromToken(const char* tok)
{
if (strcmp(tok, "unknown") == 0) return MLNL_KIND_UNKNOWN;
for (int k = 1; k < MLNL_KIND_MAX; k++)
if (strcmp(tok, MlnlKindName((MlnlKind)k)) == 0) return k;
return -1;
}
// Parse a comma-separated kind list ("tx_frame,control,...") into an enable
// mask. Sets *set true if at least one valid token was seen. Unknown tokens
// warn and are skipped.
static void ParseKindList(const char* list, bool* mask, bool* set)
{
char buf[512];
snprintf(buf, sizeof(buf), "%s", list);
for (char* tok = strtok(buf, ","); tok; tok = strtok(NULL, ",")) {
while (*tok == ' ') tok++;
int k = KindFromToken(tok);
if (k >= 0) { mask[k] = true; *set = true; }
else fprintf(stderr, "rspektrum: unknown annotation kind '%s'\n", tok);
}
}
// Compute the spectrogram for `inputArg` and write it to `renderOut` as a PNG
// without ever opening a window or touching GL. Returns a process exit code.
// annoChoice : -1 auto, 0 force overlay off, 1 force on
// annoOpacity: <0 keep default; else resting overlay alpha 0..1
// kindMask : per-kind enable flags (only consulted if kindMaskSet)
// outW : >0 resize output to this width (aspect-preserving); 0 = native
static int RunHeadlessRender(const char* inputArg, const char* renderOut,
const char* originalDir, int annoChoice,
float annoOpacity, const bool* kindMask,
bool kindMaskSet, int outW)
{
// --- GL-free init of the global state the render touches ---
app.colormap = COLORMAP_INFERNO;
app.amplitudeMode = SCALE_RELATIVE;
app.dynRangeDb = 40.0f;
app.absoluteFloorDb = -60.0f;
app.amplitudeFloorDb = -60.0f;
app.amplitudeCeilingDb = 0.0f;
app.fftSize = FFT_SIZE_DEFAULT;
app.skipFactor = 1;
app.displayMaxFreqHz = 0.0f; // full Nyquist axis (native, uncropped)
app.view.start = 0.0f; app.view.end = 1.0f;
app.view.freqStart = 0.0f; app.view.freqEnd = 1.0f;
app.showAnnotations = true;
// No hover in a headless render, so this single knob is the overall overlay
// strength (outlines solid at 1.0, fills kept lighter). Default brighter
// than the GUI's whisper-faint 0.06 so the static PNG reads on its own.
app.annotationOpacityBase = (annoOpacity >= 0.0f) ? annoOpacity : 0.5f;
app.annotationOpacityHover = app.annotationOpacityBase;
for (int i = 0; i < MLNL_KIND_MAX; i++) app.annotationKindEnabled[i] = true;
app.fftCache.count = 0;
app.fftCache.nextOrder = 0;
for (int i = 0; i < FFT_CACHE_SIZE; i++) {
app.fftCache.entries[i].fftSize = 0;
app.fftCache.entries[i].result.numSegments = 0;
app.fftCache.entries[i].result.segments = NULL;
app.fftCache.entries[i].accessOrder = 0;
}
// --- Load the WAV (resolve relative to the launch dir) ---
char resolved[8192] = { 0 };
if (!FileExists(inputArg) && originalDir[0]) {
snprintf(resolved, sizeof(resolved), "%s/%s", originalDir, inputArg);
}
const char* pathToLoad = FileExists(inputArg) ? inputArg : resolved;
if (!FileExists(pathToLoad) || !LoadWavFile(pathToLoad, &app.signal)) {
fprintf(stderr, "rspektrum: failed to load input WAV '%s'\n", inputArg);
return 1;
}
ResetForNewSignal();
LoadMlnlFromWav(pathToLoad, &app.annotations);
if (annoChoice == 0) app.showAnnotations = false;
else if (annoChoice == 1) app.showAnnotations = true;
if (kindMaskSet)
for (int i = 0; i < MLNL_KIND_MAX; i++) app.annotationKindEnabled[i] = kindMask[i];
// --- Compute the full-resolution STFT in one shot ---
ComputeSTFTInit(&app.signal, &app.stft, app.fftSize);
app.skipFactor = 1;
ComputeSTFTIncremental(&app.signal, &app.stft, app.fftSize, 0);
AutoScaleAmplitude(&app.stft);
// --- Build the spectrogram bitmap (no GL) + bake the overlay onto it ---
Image img = { 0 };
BuildSpectrogramImageCPU(&app.stft, &img);
if (img.data == NULL) {
fprintf(stderr, "rspektrum: no spectrogram data to render\n");
FreeSTFT(&app.stft);
return 1;
}
Font font = { 0 };
if (app.showAnnotations && app.annotations.loaded) {
// Font size tracks the image; cap so very tall images don't blow it up.
int fs = (int)Clamp((float)img.height / 45.0f, 12.0f, 28.0f);
font = LoadFontCPU("resources/fonts/DejaVuSansMono.ttf", fs);
if (font.glyphCount == 0) // resources/ not yet CWD — try the launch dir
font = LoadFontCPU(TextFormat("%s/resources/fonts/DejaVuSansMono.ttf", originalDir), fs);
DrawAnnotationsToImage(&img, font);
}
// --- Optional resize, then export ---
if (outW > 0 && outW != img.width) {
int outH = (int)((float)img.height * (float)outW / (float)img.width);
if (outH < 1) outH = 1;
ImageResize(&img, outW, outH);
}
char out[8192];
if (renderOut[0] == '/') snprintf(out, sizeof(out), "%s", renderOut);
else snprintf(out, sizeof(out), "%s/%s", originalDir, renderOut);
int outWf = img.width, outHf = img.height;
bool ok = ExportImage(img, out);
if (font.glyphCount > 0) UnloadFontData(font.glyphs, font.glyphCount);
if (font.recs) RL_FREE(font.recs);
UnloadImage(img);
FreeSTFT(&app.stft);
FreeMlnl(&app.annotations);
FreeSignal(&app.signal);
if (ok) { printf("Wrote %s (%dx%d)\n", out, outWf, outHf); return 0; }
fprintf(stderr, "rspektrum: failed to write '%s'\n", out);
return 1;
}
// ============================================================================
// Main Application
// ============================================================================
@@ -541,16 +678,17 @@ int main(int argc, char* argv[])
// Two modes:
// GUI: rspektrum [input.wav]
// Headless: rspektrum --render OUT.png INPUT.wav [options]
// The headless path computes the spectrogram, draws annotations, writes a
// PNG, and exits without ever showing a window (FLAG_WINDOW_HIDDEN keeps a
// GL context for rendering but puts nothing on screen).
// The headless path (RunHeadlessRender, dispatched below) computes the
// spectrogram bitmap, bakes the annotation overlay onto it, writes a PNG,
// and exits — entirely on the CPU, with no window, no GL, and no X server.
const char* inputArg = NULL; // input WAV (positional)
const char* renderOut = NULL; // --render target; non-NULL => headless mode
bool headless = false;
int annoChoice = -1; // -1 = auto (show if present), 0 = off, 1 = on
float annoOpacity = -1.0f; // <0 = keep default; else override resting overlay alpha
bool paneOnly = false; // crop to the spectrogram pane (no sidebar/scope)
int reqW = 1280, reqH = 800; // headless output size
int renderWidth = 0; // >0 resize the PNG to this width (else native STFT size)
bool kindMask[MLNL_KIND_MAX] = { false }; // which kinds to draw (if kindMaskSet)
bool kindMaskSet = false;
for (int i = 1; i < argc; i++) {
const char* a = argv[i];
@@ -565,41 +703,50 @@ int main(int argc, char* argv[])
annoOpacity = (float)atof(a + 21);
} else if (strcmp(a, "--annotation-opacity") == 0 && i + 1 < argc) {
annoOpacity = (float)atof(argv[++i]);
} else if (strcmp(a, "--pane") == 0) {
paneOnly = true;
} else if (strncmp(a, "--annotation-kinds=", 19) == 0) {
ParseKindList(a + 19, kindMask, &kindMaskSet);
} else if (strcmp(a, "--annotation-kinds") == 0 && i + 1 < argc) {
ParseKindList(argv[++i], kindMask, &kindMaskSet);
} else if (strcmp(a, "--width") == 0 && i + 1 < argc) {
reqW = atoi(argv[++i]);
} else if (strcmp(a, "--height") == 0 && i + 1 < argc) {
reqH = atoi(argv[++i]);
renderWidth = atoi(argv[++i]);
} else if (strcmp(a, "--help") == 0 || strcmp(a, "-h") == 0) {
printf(
"rspektrum - spectrogram viewer\n\n"
"Usage:\n"
" rspektrum [input.wav] open the GUI\n"
" rspektrum --render OUT.png INPUT.wav [opts] write a PNG headlessly\n\n"
"Headless options:\n"
" -r, --render OUT.png render a screenshot to OUT.png (no window)\n"
"Headless render (no window, no GL, no X server) options:\n"
" -r, --render OUT.png render the spectrogram bitmap to OUT.png\n"
" -a, --annotations force the annotation overlay on\n"
" --no-annotations force the annotation overlay off\n"
" (default: shown when the WAV carries annotations)\n"
" --annotation-opacity=V resting overlay alpha 0..1 (default 0.06, faint)\n"
" --pane capture only the spectrogram pane (no sidebar/scope)\n"
" --width N output width (default 1280)\n"
" --height N output height (default 800)\n"
" -h, --help show this help\n");
" --annotation-opacity=V overlay strength 0..1 (default 0.5)\n"
" --annotation-kinds=LIST comma-separated kinds to draw, e.g.\n"
" tx_frame,control,assertion_failed (default: all)\n"
" --width N resize output to N px wide (default: native STFT size)\n"
" -h, --help show this help\n\n"
"Annotation kinds: tx_frame, tx_burst, control, channel_up, channel_down,\n"
" assertion_passed, assertion_failed, impairment_fire, gain_change, unknown\n");
return 0;
} else if (a[0] != '-') {
if (!inputArg) inputArg = a;
}
}
if (reqW < 16) reqW = 1280;
if (reqH < 16) reqH = 800;
if (annoOpacity > 1.0f) annoOpacity = 1.0f;
if (headless && !inputArg) {
fprintf(stderr, "rspektrum: --render requires an input WAV file\n");
return 2;
}
// ---- Headless render: compute + write the PNG with no window/GL/X, exit.
if (headless) {
char cwd[4096] = { 0 };
snprintf(cwd, sizeof(cwd), "%s", GetWorkingDirectory());
SetTraceLogLevel(LOG_WARNING);
return RunHeadlessRender(inputArg, renderOut, cwd, annoChoice, annoOpacity,
kindMask, kindMaskSet, renderWidth);
}
#ifdef __EMSCRIPTEN__
// FLAG_WINDOW_HIGHDPI is buggy on the web backend: the Emscripten resize
// callback sets the screen size to window.innerWidth, but the GLFW window-
@@ -610,16 +757,9 @@ int main(int argc, char* argv[])
// resizes the canvas to the window when FLAG_WINDOW_RESIZABLE is set.
SetConfigFlags(FLAG_VSYNC_HINT | FLAG_WINDOW_RESIZABLE);
#else
if (headless) {
// Hidden window: keeps a GL context for rendering the frame, but
// nothing is ever shown. No HIGHDPI so the framebuffer matches the
// requested size exactly (LoadImageFromScreen reads it back 1:1).
SetConfigFlags(FLAG_WINDOW_HIDDEN);
} else {
SetConfigFlags(FLAG_VSYNC_HINT | FLAG_WINDOW_RESIZABLE | FLAG_WINDOW_HIGHDPI);
}
SetConfigFlags(FLAG_VSYNC_HINT | FLAG_WINDOW_RESIZABLE | FLAG_WINDOW_HIGHDPI);
#endif
InitWindow(headless ? reqW : 1280, headless ? reqH : 800, "Spectrogram Viewer");
InitWindow(1280, 800, "Spectrogram Viewer");
SetTargetFPS(ACTIVE_FPS);
SetTraceLogLevel(LOG_WARNING); // Suppress INFO texture logs
// Audio device is opened lazily on first playback (see EnsureAudioDevice)
@@ -688,8 +828,8 @@ int main(int argc, char* argv[])
app.annotationsExpanded = false;
app.annotationOpacityBase = 0.06f; // whisper-faint by default — signal wins
app.annotationOpacityHover = 0.65f; // pop on hover / selection
// CLI override: there's no hover in a headless render, so the resting alpha
// governs every overlay — bump it to make annotations read in the PNG.
// Optional CLI override of the resting overlay alpha (e.g. for a brighter
// GUI default). The headless render path sets its own default separately.
if (annoOpacity >= 0.0f) app.annotationOpacityBase = annoOpacity;
app.timelineExpanded = false;
app.hoveredTimelineEvent = -1;
@@ -733,50 +873,7 @@ int main(int argc, char* argv[])
if (!fileLoaded) TraceLog(LOG_INFO, "Press 'O' for file browser or drag & drop WAV file");
// ---- Headless render setup ----
// Compute the spectrogram synchronously here; the frame is drawn and
// captured at the bottom of the (single-pass) main loop below. headlessRc
// gates the loop: a load failure skips it entirely.
int headlessRc = 0;
char headlessOut[8192] = { 0 };
if (headless) {
if (!fileLoaded) {
fprintf(stderr, "rspektrum: failed to load input WAV '%s'\n", inputArg);
headlessRc = 1;
} else {
// Resolve the output path relative to the launch dir (CWD was
// changed to resources/ by SearchAndSetResourceDir).
if (renderOut[0] == '/') {
snprintf(headlessOut, sizeof(headlessOut), "%s", renderOut);
} else {
snprintf(headlessOut, sizeof(headlessOut), "%s/%s", originalDir, renderOut);
}
if (annoChoice == 0) app.showAnnotations = false;
else if (annoChoice == 1) app.showAnnotations = true;
// Compute the full-resolution STFT in one shot (no incremental /
// background passes — there is no interactive loop to spread them
// over). Mirrors the Emscripten single-shot path above.
ComputeSTFTInit(&app.signal, &app.stft, app.fftSize);
app.skipFactor = 1;
ComputeSTFTIncremental(&app.signal, &app.stft, app.fftSize, 0);
AutoScaleAmplitude(&app.stft);
GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage, &app.spectrogramTexture);
app.currentSTFTSegment = app.stft.numSegments;
app.bgHighResSeg = app.stft.numSegments;
app.stftComputed = true;
app.highResFinished = true;
app.bgFinished = true;
app.isBgProcessing = false;
app.loadingPhase = 0;
if (app.autocropPending) { ApplyAutoCrop(); app.autocropPending = false; }
app.autocropNoticeActive = false; // don't draw the crop splash into the shot
app.exportMessage[0] = '\0';
}
}
while (!WindowShouldClose() && headlessRc == 0)
while (!WindowShouldClose())
{
#ifdef __EMSCRIPTEN__
// Track the browser viewport (fill + reflow on resize, like desktop).
@@ -787,9 +884,8 @@ int main(int argc, char* argv[])
// below ~5% of a core. Instead we go fully event-driven when idle:
// block in PollInputEvents() (glfwWaitEvents) until an input/window
// event arrives, so an idle/backgrounded window costs ~0% CPU and only
// redraws on demand. Headless render (hidden window, single frame) opts
// out — its window never receives events, so waiting would deadlock.
if (!headless) {
// redraws on demand.
{
static double lastActive = -1000.0;
static int waiting = -1; // -1 unset, 0 = active (poll), 1 = idle (event-wait)
@@ -1666,34 +1762,6 @@ int main(int argc, char* argv[])
}
EndDrawing();
// Headless: the frame is now fully rendered. Read it back, optionally
// crop to the spectrogram pane, write the PNG, and stop the loop.
if (headless) {
Image shot = LoadImageFromScreen();
if (paneOnly) {
// Crop to the spectrogram pane: freq labels + banner + timeline
// lane + spectrogram + time-axis labels. Drops sidebar + scope.
Layout capL = ComputeLayout();
Rectangle vb = capL.viewBounds;
float top = capL.topMargin - 30.0f;
if (top < 0.0f) top = 0.0f;
float left = capL.sidebarWidth;
float right = vb.x + vb.width + capL.vScrollbarWidth + 10.0f * capL.scale;
float bottom = vb.y + vb.height + capL.labelHeight + 4.0f * capL.scale;
ImageCrop(&shot, (Rectangle){ left, top, right - left, bottom - top });
}
int outW = shot.width, outH = shot.height;
bool ok = ExportImage(shot, headlessOut);
UnloadImage(shot);
if (ok) {
printf("Wrote %s (%dx%d)\n", headlessOut, outW, outH);
} else {
fprintf(stderr, "rspektrum: failed to write '%s'\n", headlessOut);
headlessRc = 1;
}
break;
}
}
TraceLog(LOG_INFO, "Shutting down...");
@@ -1711,5 +1779,5 @@ int main(int argc, char* argv[])
FreeSignal(&app.signal);
if (IsAudioDeviceReady()) CloseAudioDevice();
CloseWindow();
return headlessRc;
return 0;
}