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
+15
View File
@@ -76,6 +76,21 @@ Trigger it from a temp keybinding or a `--shoot-after N frames` flag. This bypas
ImageMagick entirely and is the exact framebuffer — preferable when you can edit code ImageMagick entirely and is the exact framebuffer — preferable when you can edit code
(which, in a refactor task, you already are). (which, in a refactor task, you already are).
**C. A true no-display render path (if the app has one).** The strongest option when
you only need to verify *output* (not the live UI): a CLI mode that computes the frame
and writes a PNG entirely on the CPU, never calling `InitWindow` — so it needs **no
Xvfb, no GL, no X server at all**. raylib's `Image` API (`GenImageColor`, `ImageDraw*`,
`ImageDrawTextEx`, `ExportImage`) is pure CPU; only `Texture*`/`Draw*`/`BeginDrawing`
need a GL context. So a headless path can reuse the real pixel/colorize/annotation code
and skip the window. (One trap: `MeasureTextEx`/`ImageText*` short-circuit to zero when
`font.texture.id == 0`, so a CPU-loaded font — `LoadFontData` + `GenImageFontAtlas`, no
upload — must set a sentinel non-zero `texture.id` to draw text.)
> **This repo** has exactly that: `rspektrum --render OUT.png INPUT.wav` (see
> `RunHeadlessRender` in `spectrogram.c`). Use it to check spectrogram/annotation output
> with no display. The Xvfb loop below is still needed to exercise the *interactive* GUI
> (clicks, drags, hover tooltips, the live sidebar) — things the static render can't show.
Then **look at it**: open the PNG with your image-reading tool. Vision catches "the HUD Then **look at it**: open the PNG with your image-reading tool. Vision catches "the HUD
vanished" or "text is now black-on-black" that a pixel count won't explain. vanished" or "text is now black-on-black" that a pixel count won't explain.
+29 -13
View File
@@ -34,7 +34,7 @@ works as a general-purpose spectrogram tool for plain WAVs with no annotations.
- **Marker / ruler tool** and a **spectrum slice (PSD)** readout. - **Marker / ruler tool** and a **spectrum slice (PSD)** readout.
- **Export**: save the view as a **PNG**, or the selected region as a **WAV**. - **Export**: save the view as a **PNG**, or the selected region as a **WAV**.
- **Headless render mode**: produce an annotated PNG from the command line with - **Headless render mode**: produce an annotated PNG from the command line with
no window (see below). no window, no GL, and no X server — pure CPU, runs anywhere (see below).
- **Broad input support**: WAV directly (8/16-bit PCM, 32-bit float; stereo - **Broad input support**: WAV directly (8/16-bit PCM, 32-bit float; stereo
downmixed to mono); other formats transparently transcoded via `ffmpeg` if it downmixed to mono); other formats transparently transcoded via `ffmpeg` if it
is on `PATH`. Drag-and-drop loading. is on `PATH`. Drag-and-drop loading.
@@ -95,34 +95,50 @@ floor, dynamic range, annotation opacity, grid, etc.).
## Headless rendering (CLI) ## Headless rendering (CLI)
rspektrum can render an annotated spectrogram to a PNG without opening a window — `--render` writes the spectrogram straight to a PNG **with no window, no GL
useful for batch capture and for agents reviewing test output: context, and no X server** — it computes the STFT, colorizes the bitmap, bakes
the annotation overlay onto it, and exports, all on the CPU. This runs anywhere
(CI, a bare SSH session, a container with no display), not just under Xvfb:
```bash ```bash
./bin/Debug/rspektrum --render OUT.png INPUT.wav [options] ./bin/Debug/rspektrum --render OUT.png INPUT.wav [options]
``` ```
The output is the **real spectrogram bitmap** at native STFT resolution (not a
screenshot of the UI), so it carries no sidebar/scope chrome — just the
timefrequency image with the annotation overlay.
Options: Options:
| Flag | Effect | | Flag | Effect |
|------|--------| |------|--------|
| `-r, --render OUT.png` | Render to `OUT.png` and exit (no window) | | `-r, --render OUT.png` | Render to `OUT.png` and exit (no window/GL/X) |
| `-a, --annotations` | Force the annotation overlay **on** (solid, for review) | | `-a, --annotations` | Force the annotation overlay **on** |
| `--no-annotations` | Force the overlay off | | `--no-annotations` | Force the overlay off |
| `--annotation-opacity=V` | Resting overlay alpha `0..1` (default `0.06`, faint) | | `--annotation-opacity=V` | Overlay strength `0..1` (default `0.5`) |
| `--pane` | Capture only the spectrogram pane (no sidebar/scope) | | `--annotation-kinds=LIST` | Comma-separated kinds to draw (default: all) |
| `--width N` / `--height N` | Output size (default `1280×800`) | | `--width N` | Resize output to `N` px wide (default: native STFT size) |
| `-h, --help` | Usage | | `-h, --help` | Usage |
Default annotation opacity is intentionally faint (the overlay is meant to stay Annotation boxes are drawn **outline + label only** (no translucent fill): mLnL
out of the way until hovered), so for a screenshot you actually want to *review*, captures contain many overlapping full-band boxes whose fills would alpha-stack
pass `--annotations`. Example against the bundled sample: to opaque and bury the signal, so the outline marks each region while the
spectrogram reads through. `--annotation-opacity` controls outline/label
strength. Filter to just the kinds you care about with `--annotation-kinds`:
```bash ```bash
./bin/Debug/rspektrum --render /tmp/review.png mlnl_samples.wav --annotations # everything, brighter overlay
./bin/Debug/rspektrum --render /tmp/all.png mlnl_samples.wav --annotation-opacity=0.7
# only on-air frames and failed assertions
./bin/Debug/rspektrum --render /tmp/tx.png mlnl_samples.wav \
--annotation-kinds=tx_frame,assertion_failed
``` ```
`mlnl_samples.wav` is an in-repo WAV that carries an embedded `mLnL` chunk. Kinds: `tx_frame`, `tx_burst`, `control`, `channel_up`, `channel_down`,
`assertion_passed`, `assertion_failed`, `impairment_fire`, `gain_change`,
`unknown`. `mlnl_samples.wav` is an in-repo WAV that carries an embedded `mLnL`
chunk.
> The hover tooltip (sched offset, per-frame detail) only appears with a live > The hover tooltip (sched offset, per-frame detail) only appears with a live
> mouse over a box, so it cannot show up in a static `--render`. To verify > mouse over a box, so it cannot show up in a static `--render`. To verify
+150 -1
View File
@@ -222,7 +222,9 @@ static void ComputeSpectrogramReassignment(StftResult* stft)
// Map the cached reassignment buffer to colors using the current dB floor/ // Map the cached reassignment buffer to colors using the current dB floor/
// ceiling and colormap. Cheap — safe to call every frame the dB slider moves // ceiling and colormap. Cheap — safe to call every frame the dB slider moves
// or when the colormap changes (no synchrosqueezing recompute). // or when the colormap changes (no synchrosqueezing recompute).
void ColorizeSpectrogram(Image* image, Texture2D* texture) // CPU half of colorization: map the cached reassignment buffer into an RGBA
// Image. No GL — safe to call with no window / no X server (headless render).
static void ColorizeIntoImage(Image* image)
{ {
if (app.reassignBuffer == NULL) return; if (app.reassignBuffer == NULL) return;
int width = app.reassignWidth; int width = app.reassignWidth;
@@ -247,12 +249,29 @@ void ColorizeSpectrogram(Image* image, Texture2D* texture)
pixels[i] = GetColormapColor(normalized, app.colormap); pixels[i] = GetColormapColor(normalized, app.colormap);
} }
} }
}
void ColorizeSpectrogram(Image* image, Texture2D* texture)
{
ColorizeIntoImage(image);
if (image->data == NULL) return;
if (texture->id != 0) UnloadTexture(*texture); if (texture->id != 0) UnloadTexture(*texture);
*texture = LoadTextureFromImage(*image); *texture = LoadTextureFromImage(*image);
SetTextureFilter(*texture, TEXTURE_FILTER_BILINEAR); SetTextureFilter(*texture, TEXTURE_FILTER_BILINEAR);
} }
// Headless spectrogram bitmap: reassignment + colorize straight into an Image,
// with no texture upload. This is the GL-free equivalent of
// GenerateSpectrogramTexture, used by the `--render` path so a PNG can be
// produced with no window and no X server at all.
void BuildSpectrogramImageCPU(StftResult* stft, Image* image)
{
if (stft->numSegments == 0) return;
ComputeSpectrogramReassignment(stft);
ColorizeIntoImage(image);
}
// Recompute the reassignment (STFT changed) and rebuild the texture. // Recompute the reassignment (STFT changed) and rebuild the texture.
void GenerateSpectrogramTexture(StftResult* stft, Image* image, Texture2D* texture) void GenerateSpectrogramTexture(StftResult* stft, Image* image, Texture2D* texture)
{ {
@@ -1135,6 +1154,136 @@ void DrawAnnotations(Rectangle bounds)
} }
} }
// ============================================================================
// Headless (no-GL) annotation overlay — drawn straight into an Image
// ============================================================================
//
// The interactive DrawAnnotations() above issues GL draw calls and depends on
// view/zoom + hover state. The headless `--render` path has none of that: it
// exports the full-resolution spectrogram bitmap (full time span, full Nyquist
// axis), so events map directly with no view transform. These helpers mirror
// the same geometry (EventRect/EventColor/IsPointEvent) but emit CPU ImageDraw*
// calls, honoring the kind filter (annotationKindEnabled) and the resting
// overlay opacity (annotationOpacityBase). There is no hover/selection, so the
// resting alpha governs every overlay.
// Load a TTF as a Font usable by ImageDrawTextEx with NO GL context: glyph
// images + recs are populated, but no atlas texture is uploaded (ImageTextEx
// draws from font.glyphs[i].image, not font.texture). Returns a font with
// glyphCount==0 on failure; the caller treats that as "skip labels".
Font LoadFontCPU(const char* path, int baseSize)
{
Font font = { 0 };
font.baseSize = baseSize;
int dataSize = 0;
unsigned char* fileData = LoadFileData(path, &dataSize);
if (fileData == NULL) return font;
font.glyphs = LoadFontData(fileData, dataSize, baseSize, NULL, 95, FONT_DEFAULT, &font.glyphCount);
UnloadFileData(fileData);
if (font.glyphs == NULL) { font.glyphCount = 0; return font; }
// Fills font.recs (glyph source rects). We discard the packed atlas image:
// ImageTextEx reads the per-glyph images, not an atlas texture.
Image atlas = GenImageFontAtlas(font.glyphs, &font.recs, font.glyphCount, baseSize, 4, 0);
UnloadImage(atlas);
// Sentinel non-zero texture id: MeasureTextEx/ImageText* bail when it's 0
// ("security check"), but the image-drawing path reads font.glyphs[i].image,
// not the texture. We never upload or unload it (caller frees glyphs+recs).
font.texture = (Texture2D){ .id = 1 };
return font;
}
// Overlay alpha for the headless render. annotationOpacityBase is the overall
// overlay strength (0..1); `frac` weights an element within that (1.0 = a solid
// outline, lower = a translucent wash). Fills are kept light so the spectrogram
// reads through the (often full-span) annotation boxes; outlines carry the
// boundary. There is no hover/selection here, so this single knob governs all.
static unsigned char HeadlessAlpha(float frac)
{
return (unsigned char)Clamp(app.annotationOpacityBase * frac * 255.0f, 0.0f, 255.0f);
}
// Brighten an event color toward white so labels stay legible at full alpha,
// even when the box fill shares the same hue.
static Color LabelColor(Color c)
{
return (Color){ (unsigned char)(c.r + (255 - c.r) * 0.55f),
(unsigned char)(c.g + (255 - c.g) * 0.55f),
(unsigned char)(c.b + (255 - c.b) * 0.55f), 255 };
}
static void ImageBoxLabel(Image* img, Font font, Rectangle box, const char* text,
Color color, int fontSize)
{
if (font.glyphCount == 0 || !text || !*text || box.width < 18.0f) return;
Vector2 ts = MeasureTextEx(font, text, (float)fontSize, 1.0f);
// Dark backing strip so labels stay legible over a bright spectrogram.
ImageDrawRectangleRec(img, (Rectangle){ box.x + 1, box.y + 1, ts.x + 4, ts.y + 2 },
(Color){ 0, 0, 0, 165 });
ImageDrawTextEx(img, font, text, (Vector2){ box.x + 3, box.y + 2 },
(float)fontSize, 1.0f, color);
}
// Draw the mLnL overlay onto a full-resolution spectrogram Image (no GL).
void DrawAnnotationsToImage(Image* img, Font font)
{
if (!app.annotations.loaded || !app.showAnnotations) return;
if (app.signal.duration <= 0.0f || img->data == NULL) return;
Rectangle bounds = { 0, 0, (float)img->width, (float)img->height };
double duration = app.signal.duration;
double nyquist = EffectiveMaxFreqHz();
int fontSize = (int)Clamp((float)img->height / 45.0f, 11.0f, 28.0f);
// ---- Layer 1: ranged fills (tx_burst, tx_frame; then assertions) ----
for (int i = 0; i < app.annotations.eventCount; i++) {
const MlnlEvent* e = &app.annotations.events[i];
if (e->kind != MLNL_KIND_TX_BURST && e->kind != MLNL_KIND_TX_FRAME) continue;
if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
Rectangle r;
if (!EventRect(bounds, e, duration, nyquist, &r)) continue;
Color c = EventColor(e);
// Outline-only. Translucent fills alpha-stack to opaque wherever frames
// overlap (a PTT is many adjacent/overlapping frames), which buries the
// signal — exactly the energy these boxes annotate. The border + label
// mark each frame while its transmission still reads underneath.
ImageDrawRectangleLines(img, r, 2, (Color){ c.r, c.g, c.b, HeadlessAlpha(1.0f) });
char lbl[160];
if (e->kind == MLNL_KIND_TX_FRAME) FormatTxFrameLabel(e, lbl, sizeof(lbl));
else snprintf(lbl, sizeof(lbl), "%s", e->has_note ? e->note : e->kindStr);
ImageBoxLabel(img, font, r, lbl, LabelColor(c), fontSize);
}
for (int i = 0; i < app.annotations.eventCount; i++) {
const MlnlEvent* e = &app.annotations.events[i];
if (e->kind != MLNL_KIND_ASSERTION_PASSED && e->kind != MLNL_KIND_ASSERTION_FAILED) continue;
if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
if (e->t_end <= e->t_start) continue;
Rectangle r;
if (!EventRect(bounds, e, duration, nyquist, &r)) continue;
Color c = EventColor(e);
// Outline-only: assertions are often full-band and overlap, so a fill
// would stack to opaque and bury the signal. The 2px border marks the
// region; passed/failed read from its color.
ImageDrawRectangleLines(img, r, 2, (Color){ c.r, c.g, c.b, HeadlessAlpha(1.0f) });
ImageBoxLabel(img, font, r, e->has_note ? e->note : (e->has_name ? e->name : e->kindStr),
LabelColor(c), fontSize);
}
// ---- Layer 2: point / vertical-line events (control, channel_*, etc.) ----
for (int i = 0; i < app.annotations.eventCount; i++) {
const MlnlEvent* e = &app.annotations.events[i];
if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
if (!IsPointEvent(e)) continue;
Vector2 p = AnnoToScreen(bounds, e->t_start, 0.0, duration, nyquist);
if (p.x < bounds.x || p.x > bounds.x + bounds.width) continue;
Color c = EventColor(e);
Color line = (Color){ c.r, c.g, c.b, HeadlessAlpha(1.0f) };
ImageDrawLineEx(img, (Vector2){ p.x, bounds.y }, (Vector2){ p.x, bounds.y + bounds.height },
2, line);
ImageBoxLabel(img, font, (Rectangle){ p.x + 2, bounds.y, 200, 20 },
e->has_note ? e->note : e->kindStr, line, fontSize);
}
}
// ============================================================================ // ============================================================================
// Annotation overlay on the waveform scope (time-axis only) // Annotation overlay on the waveform scope (time-axis only)
// ============================================================================ // ============================================================================
+10
View File
@@ -20,6 +20,16 @@ const char* ColormapName(ColormapType type);
void GenerateSpectrogramTexture(StftResult* stft, Image* image, Texture2D* texture); void GenerateSpectrogramTexture(StftResult* stft, Image* image, Texture2D* texture);
void ColorizeSpectrogram(Image* image, Texture2D* texture); void ColorizeSpectrogram(Image* image, Texture2D* texture);
// --- Headless (no-GL) spectrogram + annotation rendering ---
// BuildSpectrogramImageCPU fills `image` with the colorized spectrogram with no
// texture upload, so it runs with no window / no X server. LoadFontCPU returns
// a Font usable by ImageDrawTextEx without a GL context (glyphCount==0 on
// failure). DrawAnnotationsToImage paints the mLnL overlay onto a full-
// resolution spectrogram Image, honoring the kind filter and resting opacity.
void BuildSpectrogramImageCPU(StftResult* stft, Image* image);
Font LoadFontCPU(const char* path, int baseSize);
void DrawAnnotationsToImage(Image* img, Font font);
// --- On-screen drawing (operate on the global app state) --- // --- On-screen drawing (operate on the global app state) ---
void DrawSpectrogramGrid(Rectangle bounds, int numCellsX, int numCellsY, Color color); void DrawSpectrogramGrid(Rectangle bounds, int numCellsX, int numCellsY, Color color);
void DrawLabels(Rectangle bounds); void DrawLabels(Rectangle bounds);
+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 // Main Application
// ============================================================================ // ============================================================================
@@ -541,16 +678,17 @@ int main(int argc, char* argv[])
// Two modes: // Two modes:
// GUI: rspektrum [input.wav] // GUI: rspektrum [input.wav]
// Headless: rspektrum --render OUT.png INPUT.wav [options] // Headless: rspektrum --render OUT.png INPUT.wav [options]
// The headless path computes the spectrogram, draws annotations, writes a // The headless path (RunHeadlessRender, dispatched below) computes the
// PNG, and exits without ever showing a window (FLAG_WINDOW_HIDDEN keeps a // spectrogram bitmap, bakes the annotation overlay onto it, writes a PNG,
// GL context for rendering but puts nothing on screen). // and exits — entirely on the CPU, with no window, no GL, and no X server.
const char* inputArg = NULL; // input WAV (positional) const char* inputArg = NULL; // input WAV (positional)
const char* renderOut = NULL; // --render target; non-NULL => headless mode const char* renderOut = NULL; // --render target; non-NULL => headless mode
bool headless = false; bool headless = false;
int annoChoice = -1; // -1 = auto (show if present), 0 = off, 1 = on int annoChoice = -1; // -1 = auto (show if present), 0 = off, 1 = on
float annoOpacity = -1.0f; // <0 = keep default; else override resting overlay alpha float annoOpacity = -1.0f; // <0 = keep default; else override resting overlay alpha
bool paneOnly = false; // crop to the spectrogram pane (no sidebar/scope) int renderWidth = 0; // >0 resize the PNG to this width (else native STFT size)
int reqW = 1280, reqH = 800; // headless output size bool kindMask[MLNL_KIND_MAX] = { false }; // which kinds to draw (if kindMaskSet)
bool kindMaskSet = false;
for (int i = 1; i < argc; i++) { for (int i = 1; i < argc; i++) {
const char* a = argv[i]; const char* a = argv[i];
@@ -565,41 +703,50 @@ int main(int argc, char* argv[])
annoOpacity = (float)atof(a + 21); annoOpacity = (float)atof(a + 21);
} else if (strcmp(a, "--annotation-opacity") == 0 && i + 1 < argc) { } else if (strcmp(a, "--annotation-opacity") == 0 && i + 1 < argc) {
annoOpacity = (float)atof(argv[++i]); annoOpacity = (float)atof(argv[++i]);
} else if (strcmp(a, "--pane") == 0) { } else if (strncmp(a, "--annotation-kinds=", 19) == 0) {
paneOnly = true; 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) { } else if (strcmp(a, "--width") == 0 && i + 1 < argc) {
reqW = atoi(argv[++i]); renderWidth = atoi(argv[++i]);
} else if (strcmp(a, "--height") == 0 && i + 1 < argc) {
reqH = atoi(argv[++i]);
} else if (strcmp(a, "--help") == 0 || strcmp(a, "-h") == 0) { } else if (strcmp(a, "--help") == 0 || strcmp(a, "-h") == 0) {
printf( printf(
"rspektrum - spectrogram viewer\n\n" "rspektrum - spectrogram viewer\n\n"
"Usage:\n" "Usage:\n"
" rspektrum [input.wav] open the GUI\n" " rspektrum [input.wav] open the GUI\n"
" rspektrum --render OUT.png INPUT.wav [opts] write a PNG headlessly\n\n" " rspektrum --render OUT.png INPUT.wav [opts] write a PNG headlessly\n\n"
"Headless options:\n" "Headless render (no window, no GL, no X server) options:\n"
" -r, --render OUT.png render a screenshot to OUT.png (no window)\n" " -r, --render OUT.png render the spectrogram bitmap to OUT.png\n"
" -a, --annotations force the annotation overlay on\n" " -a, --annotations force the annotation overlay on\n"
" --no-annotations force the annotation overlay off\n" " --no-annotations force the annotation overlay off\n"
" (default: shown when the WAV carries annotations)\n" " (default: shown when the WAV carries annotations)\n"
" --annotation-opacity=V resting overlay alpha 0..1 (default 0.06, faint)\n" " --annotation-opacity=V overlay strength 0..1 (default 0.5)\n"
" --pane capture only the spectrogram pane (no sidebar/scope)\n" " --annotation-kinds=LIST comma-separated kinds to draw, e.g.\n"
" --width N output width (default 1280)\n" " tx_frame,control,assertion_failed (default: all)\n"
" --height N output height (default 800)\n" " --width N resize output to N px wide (default: native STFT size)\n"
" -h, --help show this help\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; return 0;
} else if (a[0] != '-') { } else if (a[0] != '-') {
if (!inputArg) inputArg = a; if (!inputArg) inputArg = a;
} }
} }
if (reqW < 16) reqW = 1280;
if (reqH < 16) reqH = 800;
if (annoOpacity > 1.0f) annoOpacity = 1.0f; if (annoOpacity > 1.0f) annoOpacity = 1.0f;
if (headless && !inputArg) { if (headless && !inputArg) {
fprintf(stderr, "rspektrum: --render requires an input WAV file\n"); fprintf(stderr, "rspektrum: --render requires an input WAV file\n");
return 2; 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__ #ifdef __EMSCRIPTEN__
// FLAG_WINDOW_HIGHDPI is buggy on the web backend: the Emscripten resize // FLAG_WINDOW_HIGHDPI is buggy on the web backend: the Emscripten resize
// callback sets the screen size to window.innerWidth, but the GLFW window- // 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. // resizes the canvas to the window when FLAG_WINDOW_RESIZABLE is set.
SetConfigFlags(FLAG_VSYNC_HINT | FLAG_WINDOW_RESIZABLE); SetConfigFlags(FLAG_VSYNC_HINT | FLAG_WINDOW_RESIZABLE);
#else #else
if (headless) { SetConfigFlags(FLAG_VSYNC_HINT | FLAG_WINDOW_RESIZABLE | FLAG_WINDOW_HIGHDPI);
// 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);
}
#endif #endif
InitWindow(headless ? reqW : 1280, headless ? reqH : 800, "Spectrogram Viewer"); InitWindow(1280, 800, "Spectrogram Viewer");
SetTargetFPS(ACTIVE_FPS); SetTargetFPS(ACTIVE_FPS);
SetTraceLogLevel(LOG_WARNING); // Suppress INFO texture logs SetTraceLogLevel(LOG_WARNING); // Suppress INFO texture logs
// Audio device is opened lazily on first playback (see EnsureAudioDevice) // Audio device is opened lazily on first playback (see EnsureAudioDevice)
@@ -688,8 +828,8 @@ int main(int argc, char* argv[])
app.annotationsExpanded = false; app.annotationsExpanded = false;
app.annotationOpacityBase = 0.06f; // whisper-faint by default — signal wins app.annotationOpacityBase = 0.06f; // whisper-faint by default — signal wins
app.annotationOpacityHover = 0.65f; // pop on hover / selection app.annotationOpacityHover = 0.65f; // pop on hover / selection
// CLI override: there's no hover in a headless render, so the resting alpha // Optional CLI override of the resting overlay alpha (e.g. for a brighter
// governs every overlay — bump it to make annotations read in the PNG. // GUI default). The headless render path sets its own default separately.
if (annoOpacity >= 0.0f) app.annotationOpacityBase = annoOpacity; if (annoOpacity >= 0.0f) app.annotationOpacityBase = annoOpacity;
app.timelineExpanded = false; app.timelineExpanded = false;
app.hoveredTimelineEvent = -1; 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"); if (!fileLoaded) TraceLog(LOG_INFO, "Press 'O' for file browser or drag & drop WAV file");
// ---- Headless render setup ---- while (!WindowShouldClose())
// 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)
{ {
#ifdef __EMSCRIPTEN__ #ifdef __EMSCRIPTEN__
// Track the browser viewport (fill + reflow on resize, like desktop). // 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: // below ~5% of a core. Instead we go fully event-driven when idle:
// block in PollInputEvents() (glfwWaitEvents) until an input/window // block in PollInputEvents() (glfwWaitEvents) until an input/window
// event arrives, so an idle/backgrounded window costs ~0% CPU and only // event arrives, so an idle/backgrounded window costs ~0% CPU and only
// redraws on demand. Headless render (hidden window, single frame) opts // redraws on demand.
// out — its window never receives events, so waiting would deadlock. {
if (!headless) {
static double lastActive = -1000.0; static double lastActive = -1000.0;
static int waiting = -1; // -1 unset, 0 = active (poll), 1 = idle (event-wait) static int waiting = -1; // -1 unset, 0 = active (poll), 1 = idle (event-wait)
@@ -1666,34 +1762,6 @@ int main(int argc, char* argv[])
} }
EndDrawing(); 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..."); TraceLog(LOG_INFO, "Shutting down...");
@@ -1711,5 +1779,5 @@ int main(int argc, char* argv[])
FreeSignal(&app.signal); FreeSignal(&app.signal);
if (IsAudioDeviceReady()) CloseAudioDevice(); if (IsAudioDeviceReady()) CloseAudioDevice();
CloseWindow(); CloseWindow();
return headlessRc; return 0;
} }