diff --git a/AGENTS.md b/AGENTS.md index 759bbd5..6ad5158 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 (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 vanished" or "text is now black-on-black" that a pixel count won't explain. diff --git a/README.md b/README.md index 1749c37..d8efdeb 100644 --- a/README.md +++ b/README.md @@ -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. - **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 - 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 downmixed to mono); other formats transparently transcoded via `ffmpeg` if it is on `PATH`. Drag-and-drop loading. @@ -95,34 +95,50 @@ floor, dynamic range, annotation opacity, grid, etc.). ## Headless rendering (CLI) -rspektrum can render an annotated spectrogram to a PNG without opening a window — -useful for batch capture and for agents reviewing test output: +`--render` writes the spectrogram straight to a PNG **with no window, no GL +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 ./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 +time–frequency image with the annotation overlay. + Options: | Flag | Effect | |------|--------| -| `-r, --render OUT.png` | Render to `OUT.png` and exit (no window) | -| `-a, --annotations` | Force the annotation overlay **on** (solid, for review) | +| `-r, --render OUT.png` | Render to `OUT.png` and exit (no window/GL/X) | +| `-a, --annotations` | Force the annotation overlay **on** | | `--no-annotations` | Force the overlay off | -| `--annotation-opacity=V` | Resting overlay alpha `0..1` (default `0.06`, faint) | -| `--pane` | Capture only the spectrogram pane (no sidebar/scope) | -| `--width N` / `--height N` | Output size (default `1280×800`) | +| `--annotation-opacity=V` | Overlay strength `0..1` (default `0.5`) | +| `--annotation-kinds=LIST` | Comma-separated kinds to draw (default: all) | +| `--width N` | Resize output to `N` px wide (default: native STFT size) | | `-h, --help` | Usage | -Default annotation opacity is intentionally faint (the overlay is meant to stay -out of the way until hovered), so for a screenshot you actually want to *review*, -pass `--annotations`. Example against the bundled sample: +Annotation boxes are drawn **outline + label only** (no translucent fill): mLnL +captures contain many overlapping full-band boxes whose fills would alpha-stack +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 -./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 > mouse over a box, so it cannot show up in a static `--render`. To verify diff --git a/src/render.c b/src/render.c index a60e410..08b2df1 100644 --- a/src/render.c +++ b/src/render.c @@ -222,7 +222,9 @@ static void ComputeSpectrogramReassignment(StftResult* stft) // 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 // 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; int width = app.reassignWidth; @@ -247,12 +249,29 @@ void ColorizeSpectrogram(Image* image, Texture2D* texture) 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); *texture = LoadTextureFromImage(*image); 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. 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) // ============================================================================ diff --git a/src/render.h b/src/render.h index 0aed709..12c88e0 100644 --- a/src/render.h +++ b/src/render.h @@ -20,6 +20,16 @@ const char* ColormapName(ColormapType type); void GenerateSpectrogramTexture(StftResult* stft, 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) --- void DrawSpectrogramGrid(Rectangle bounds, int numCellsX, int numCellsY, Color color); void DrawLabels(Rectangle bounds); diff --git a/src/spectrogram.c b/src/spectrogram.c index b390d9e..e2b73bd 100644 --- a/src/spectrogram.c +++ b/src/spectrogram.c @@ -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; }