feat: headless PNG render, mLnL annotations, and per-frame sched offset

Commit the accumulated working-tree changes as one snapshot.

- Headless render: `--render OUT.png INPUT.wav` draws the spectrogram
  (full window, or `--pane` for the spectrogram pane only) to a PNG
  with no visible window. Options: `--annotations`/`--no-annotations`,
  `--annotation-opacity`, `--width`/`--height`.
- mLnL annotations: parse the optional `mLnL` RIFF chunk (schema v2)
  and render tx_frame/assertion/control overlays, a timeline lane, and
  a waveform-scope echo, with hover tooltips on the spectrogram,
  timeline, and scope.
- sched_offset_ms: parse the per-frame intent->air latency and surface
  it in the hover tooltips (boxes stay air-anchored upstream).
- Supporting: build wiring (rspektrum.make), shared types/headers,
  web-build and capture-script tweaks, and removal of the old
  synchrosqueezing LaTeX doc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-29 12:19:37 -07:00
parent cef7619833
commit ac262505c1
17 changed files with 2263 additions and 257 deletions
+485 -23
View File
@@ -91,10 +91,22 @@ typedef struct {
float vScrollbarWidth;
float topMargin;
float bottomMargin;
float spectroHeight; // height of the spectrogram (respects the scope divider)
Rectangle viewBounds; // the spectrogram drawing area
float spectroHeight; // height of the spectrogram (respects the scope divider AND the timeline lane)
float timelineHeight; // 0 if no annotations / lane hidden
Rectangle viewBounds; // the spectrogram drawing area
Rectangle timelineBounds;// the annotations timeline lane (zero-sized if not shown)
} Layout;
// Number of MlnlKind rows that should be visible in the expanded timeline:
// kinds present in this file AND not filtered out by the per-kind checkboxes.
static int CountVisibleAnnotationKinds(void)
{
int n = 0;
for (int k = 0; k < MLNL_KIND_MAX; k++)
if (app.annotations.kindPresent[k] && app.annotationKindEnabled[k]) n++;
return n;
}
static Layout ComputeLayout(void)
{
Layout L;
@@ -107,12 +119,35 @@ static Layout ComputeLayout(void)
L.topMargin = 50 * L.scale;
L.bottomMargin = 10 * L.scale;
L.spectroHeight = (GetScreenHeight() - L.topMargin - L.bottomMargin - L.labelHeight - L.scrollbarHeight - 10 * L.scale) * ScopeDivider();
// Timeline lane sits above the spectrogram, eating from spectro height
// (not from the scope area). Collapsed is a thin sparkline; expanded grows
// by one row per enabled kind. Only present when the file carries
// annotations and the master toggle is on.
L.timelineHeight = 0;
if (app.annotations.loaded && app.annotations.eventCount > 0 && app.showAnnotations) {
if (app.timelineExpanded) {
int rows = CountVisibleAnnotationKinds();
if (rows < 1) rows = 1;
L.timelineHeight = (rows * 14.0f + 4.0f) * L.scale;
} else {
L.timelineHeight = 10.0f * L.scale;
}
}
float laneX = L.sidebarWidth + L.freqLabelWidth;
float laneW = GetScreenWidth() - L.sidebarWidth - L.freqLabelWidth - L.vScrollbarWidth - 20 * L.scale;
L.timelineBounds = (Rectangle){ laneX, L.topMargin, laneW, L.timelineHeight };
// Spectrogram starts below the lane (with a 2-pixel gap) and shrinks accordingly.
float gap = (L.timelineHeight > 0) ? 2.0f * L.scale : 0.0f;
L.viewBounds = (Rectangle){
L.sidebarWidth + L.freqLabelWidth,
L.topMargin,
GetScreenWidth() - L.sidebarWidth - L.freqLabelWidth - L.vScrollbarWidth - 20 * L.scale,
L.spectroHeight
laneX,
L.topMargin + L.timelineHeight + gap,
laneW,
L.spectroHeight - L.timelineHeight - gap
};
L.spectroHeight = L.viewBounds.height;
return L;
}
@@ -160,6 +195,245 @@ void ResetForNewSignal(void)
if (app.visibleTexture.id != 0) UnloadTexture(app.visibleTexture);
app.visibleTexture = (Texture2D){ 0 };
app.visibleTextureValid = false;
// Drop the previous file's annotations; the caller re-parses from the new
// path after this returns (LoadMlnlFromWav needs the source path that
// raylib's LoadWave already consumed).
FreeMlnl(&app.annotations);
app.hoveredEvent = -1;
app.hoveredTimelineEvent = -1;
app.selectedAnnotation = -1;
app.autocropPending = true; // run once when this file's STFT is ready
}
// ============================================================================
// Auto-crop: shrink the displayed freq axis + time view to where the data
// actually lives. Two independent sources, tried in priority order.
// ============================================================================
// 15% headroom above the highest annotated f_hi keeps event boxes from
// touching the top edge; 5% time padding gives breathing room around the
// outermost events without pushing them into the corners.
#define AUTOCROP_FREQ_HEADROOM 1.15f
#define AUTOCROP_TIME_PAD_FRAC 0.05f
// Confidence thresholds for the energy heuristic. If the cropped freq band
// would still cover >80% of Nyquist, or the cropped time would cover >90%
// of the timeline, the signal genuinely uses most of the available range
// and we leave the view alone (the crop would only be churn).
#define AUTOCROP_FREQ_MAX_FRAC 0.80f
#define AUTOCROP_TIME_MAX_FRAC 0.90f
// Cumulative energy fraction that defines "where signal lives". 0.99 means
// the cropped freq range holds 99% of the spectrogram's total power.
#define AUTOCROP_FREQ_ENERGY 0.99
// Per-segment activity threshold (fraction of the peak segment's energy).
// Anything below this is treated as silence at the timeline edges.
#define AUTOCROP_TIME_ACTIVITY 0.01
// Annotation-driven crop: trusts the producer. Always confident when ≥1
// annotation has f_hi or any have a non-zero time span. Returns the computed
// crop in the out-params; leaves them at "no crop" values on failure.
static bool ComputeAnnotationCrop(float* outFreqMaxHz, float* outViewStart, float* outViewEnd)
{
*outFreqMaxHz = 0.0f;
*outViewStart = 0.0f; *outViewEnd = 1.0f;
if (!app.annotations.loaded || app.annotations.eventCount == 0) return false;
double fMax = 0.0;
double tMin = 1e18, tMax = -1e18;
bool anyFreq = false, anyTime = false;
for (int i = 0; i < app.annotations.eventCount; i++) {
const MlnlEvent* e = &app.annotations.events[i];
if (e->has_freq && e->f_hi_hz > fMax) { fMax = e->f_hi_hz; anyFreq = true; }
if (e->t_end >= e->t_start) {
if (e->t_start < tMin) tMin = e->t_start;
if (e->t_end > tMax) tMax = e->t_end;
anyTime = true;
}
}
if (anyFreq) *outFreqMaxHz = (float)(fMax * AUTOCROP_FREQ_HEADROOM);
if (anyTime && app.signal.duration > 0.0f) {
double pad = (tMax - tMin) * AUTOCROP_TIME_PAD_FRAC;
double s = tMin - pad, e = tMax + pad;
if (s < 0.0) s = 0.0;
if (e > app.signal.duration) e = app.signal.duration;
if (e > s) {
*outViewStart = (float)(s / app.signal.duration);
*outViewEnd = (float)(e / app.signal.duration);
}
}
return anyFreq || anyTime;
}
// Energy heuristic: walk the STFT, build per-bin and per-segment energy.
// Crop freq if 99% of energy fits below 80% of Nyquist; crop time if the
// activity envelope occupies <90% of the timeline. Returns true if at least
// one axis was confidently cropped.
static bool ComputeEnergyCrop(float* outFreqMaxHz, float* outViewStart, float* outViewEnd)
{
*outFreqMaxHz = 0.0f;
*outViewStart = 0.0f; *outViewEnd = 1.0f;
if (app.stft.numSegments < 2) return false;
int nbins = 0;
for (int s = 0; s < app.stft.numSegments; s++) {
if (app.stft.segments[s].spectrum && app.stft.segments[s].numBins > nbins)
nbins = app.stft.segments[s].numBins;
}
if (nbins < 4) return false;
int nsegs = app.stft.numSegments;
double* binE = (double*)calloc((size_t)nbins, sizeof(double));
double* segE = (double*)calloc((size_t)nsegs, sizeof(double));
if (!binE || !segE) { free(binE); free(segE); return false; }
double totalE = 0.0, segPeak = 0.0;
for (int s = 0; s < nsegs; s++) {
if (!app.stft.segments[s].spectrum) continue;
int nb = app.stft.segments[s].numBins;
for (int b = 0; b < nb; b++) {
double a = app.stft.segments[s].spectrum[b].amplitude;
double e = a * a;
binE[b] += e;
segE[s] += e;
totalE += e;
}
if (segE[s] > segPeak) segPeak = segE[s];
}
bool didCrop = false;
// --- Freq axis: smallest bin whose cumulative energy reaches 99%. ---
if (totalE > 0.0) {
double thr = totalE * AUTOCROP_FREQ_ENERGY;
double cum = 0.0;
int cropBin = nbins - 1;
for (int b = 0; b < nbins; b++) {
cum += binE[b];
if (cum >= thr) { cropBin = b; break; }
}
float fraction = (float)cropBin / (float)(nbins - 1);
if (fraction <= AUTOCROP_FREQ_MAX_FRAC) {
float nyq = app.signal.sampleRate * 0.5f;
*outFreqMaxHz = fraction * nyq * AUTOCROP_FREQ_HEADROOM;
didCrop = true;
}
}
// --- Time axis: activity envelope at 1% of segment-peak energy. ---
if (segPeak > 0.0) {
double thr = segPeak * AUTOCROP_TIME_ACTIVITY;
int first = -1, last = -1;
for (int s = 0; s < nsegs; s++) {
if (segE[s] >= thr) { if (first < 0) first = s; last = s; }
}
if (first >= 0 && last > first) {
float coverage = (float)(last - first + 1) / (float)nsegs;
if (coverage <= AUTOCROP_TIME_MAX_FRAC) {
float s0 = (float)first / (float)nsegs;
float s1 = (float)(last + 1) / (float)nsegs;
float pad = (s1 - s0) * AUTOCROP_TIME_PAD_FRAC;
s0 -= pad; s1 += pad;
if (s0 < 0.0f) s0 = 0.0f;
if (s1 > 1.0f) s1 = 1.0f;
*outViewStart = s0;
*outViewEnd = s1;
didCrop = true;
}
}
}
free(binE); free(segE);
return didCrop;
}
void ApplyAutoCrop(void)
{
if (app.signal.sampleRate <= 0 || app.signal.duration <= 0.0f) return;
float nyq = app.signal.sampleRate * 0.5f;
// Compute BOTH heuristics for BOTH axes, then pick the more focused
// result per axis. Annotations can be authoritative for freq (the
// producer knows the band) yet wide for time (a single late `control`
// marker can span almost the whole file even if signal activity ended
// long before) — so we don't tie the time choice to the freq choice.
float aFreq = 0.0f, aStart = 0.0f, aEnd = 1.0f;
float eFreq = 0.0f, eStart = 0.0f, eEnd = 1.0f;
ComputeAnnotationCrop(&aFreq, &aStart, &aEnd);
ComputeEnergyCrop(&eFreq, &eStart, &eEnd);
// ---- Freq axis: smaller cropped max wins. ----
// Both candidates are 0 when the source didn't propose a crop; treat
// those as "didn't propose" rather than "crop to 0".
float freqMax = 0.0f;
const char* freqSrc = NULL;
if (aFreq > 0.0f && eFreq > 0.0f) {
if (eFreq < aFreq) { freqMax = eFreq; freqSrc = "energy"; }
else { freqMax = aFreq; freqSrc = "annotations"; }
} else if (aFreq > 0.0f) { freqMax = aFreq; freqSrc = "annotations"; }
else if (eFreq > 0.0f) { freqMax = eFreq; freqSrc = "energy"; }
// ---- Time axis: more focused (shorter) range wins. ----
// Each source's output is a 0..1 fraction of the signal duration; a
// value of [0..1] means "didn't crop". We bias against picking a source
// that's effectively the whole timeline.
bool aShrunk = (aEnd - aStart) < 0.999f;
bool eShrunk = (eEnd - eStart) < 0.999f;
float vStart = 0.0f, vEnd = 1.0f;
const char* timeSrc = NULL;
if (aShrunk && eShrunk) {
if ((eEnd - eStart) < (aEnd - aStart)) {
vStart = eStart; vEnd = eEnd; timeSrc = "energy";
} else {
vStart = aStart; vEnd = aEnd; timeSrc = "annotations";
}
} else if (aShrunk) { vStart = aStart; vEnd = aEnd; timeSrc = "annotations"; }
else if (eShrunk) { vStart = eStart; vEnd = eEnd; timeSrc = "energy"; }
bool freqChanged = (freqMax > 0.0f && freqMax < nyq * 0.99f);
bool timeChanged = (timeSrc != NULL);
if (!freqChanged && !timeChanged) {
TraceLog(LOG_INFO, "Auto-crop: no confident source, leaving view alone");
return;
}
if (freqChanged) {
if (freqMax > nyq) freqMax = nyq;
app.displayMaxFreqHz = freqMax;
}
if (timeChanged) {
app.view.start = vStart;
app.view.end = vEnd;
}
// Fit freq view to the cropped band; otherwise a prior zoom would
// double-zoom on top of the new crop.
app.view.freqStart = 0.0f;
app.view.freqEnd = 1.0f;
app.visibleTextureValid = false;
// Splash message — mention per-axis source separately when they diverge
// (e.g. freq from annotations, time from energy on a file with a stray
// late control event).
char freqPart[64] = "", timePart[64] = "";
if (freqChanged) snprintf(freqPart, sizeof(freqPart), "0-%.0f Hz", freqMax);
if (timeChanged) snprintf(timePart, sizeof(timePart), "%.2f-%.2f s",
vStart * app.signal.duration, vEnd * app.signal.duration);
char srcPart[80];
if (freqChanged && timeChanged && freqSrc && timeSrc && strcmp(freqSrc, timeSrc) != 0) {
snprintf(srcPart, sizeof(srcPart), "freq: %s, time: %s", freqSrc, timeSrc);
} else {
const char* s = freqSrc ? freqSrc : timeSrc;
snprintf(srcPart, sizeof(srcPart), "%s", s ? s : "auto");
}
snprintf(app.autocropNoticeMsg, sizeof(app.autocropNoticeMsg),
"View auto-cropped to %s%s%s (%s).",
freqPart,
(freqChanged && timeChanged) ? ", " : "",
timePart,
srcPart);
app.autocropNoticeActive = true;
TraceLog(LOG_INFO, "Auto-crop: %s", app.autocropNoticeMsg);
}
// ============================================================================
@@ -234,6 +508,69 @@ static void DispatchKeymap(void)
int main(int argc, char* argv[])
{
// ---- Command-line arguments ----
// 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).
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
for (int i = 1; i < argc; i++) {
const char* a = argv[i];
if ((strcmp(a, "--render") == 0 || strcmp(a, "-r") == 0) && i + 1 < argc) {
renderOut = argv[++i];
headless = true;
} else if (strcmp(a, "--annotations") == 0 || strcmp(a, "-a") == 0) {
annoChoice = 1;
} else if (strcmp(a, "--no-annotations") == 0) {
annoChoice = 0;
} else if (strncmp(a, "--annotation-opacity=", 21) == 0) {
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 (strcmp(a, "--width") == 0 && i + 1 < argc) {
reqW = 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) {
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"
" -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");
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;
}
#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-
@@ -244,9 +581,16 @@ 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
SetConfigFlags(FLAG_VSYNC_HINT | FLAG_WINDOW_RESIZABLE | FLAG_WINDOW_HIGHDPI);
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);
}
#endif
InitWindow(1280, 800, "Spectrogram Viewer");
InitWindow(headless ? reqW : 1280, headless ? reqH : 800, "Spectrogram Viewer");
SetTargetFPS(60);
SetTraceLogLevel(LOG_WARNING); // Suppress INFO texture logs
InitAudioDevice();
@@ -309,6 +653,18 @@ int main(int argc, char* argv[])
}
app.isPlaying = false;
app.playbackFinished = false;
app.displayMaxFreqHz = 0.0f; // 0 = no crop; user sets via sidebar slider
app.showAnnotations = true;
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.
if (annoOpacity >= 0.0f) app.annotationOpacityBase = annoOpacity;
app.timelineExpanded = false;
app.hoveredTimelineEvent = -1;
app.selectedAnnotation = -1;
for (int i = 0; i < MLNL_KIND_MAX; i++) app.annotationKindEnabled[i] = true;
app.showScope = true;
app.dividerY = 0.6f; // Start with 60% spectro, 40% scope
app.isDividing = false;
@@ -326,27 +682,71 @@ int main(int argc, char* argv[])
TraceLog(LOG_INFO, "Spectrogram Viewer initialized");
bool fileLoaded = false;
if (argc > 1) {
TraceLog(LOG_INFO, "Loading file from command line: %s", argv[1]);
if (inputArg) {
TraceLog(LOG_INFO, "Loading file from command line: %s", inputArg);
char resolvedPath[8192] = { 0 };
// If the path doesn't exist as-is, try prepending original dir
if (!FileExists(argv[1]) && originalDir[0]) {
snprintf(resolvedPath, sizeof(resolvedPath), "%s/%s", originalDir, argv[1]);
if (!FileExists(inputArg) && originalDir[0]) {
snprintf(resolvedPath, sizeof(resolvedPath), "%s/%s", originalDir, inputArg);
TraceLog(LOG_INFO, "Trying prepended path: %s", resolvedPath);
}
const char* pathToLoad = FileExists(argv[1]) ? argv[1] : resolvedPath;
const char* pathToLoad = FileExists(inputArg) ? inputArg : resolvedPath;
if (FileExists(pathToLoad) && LoadWavFile(pathToLoad, &app.signal)) {
fileLoaded = true;
ResetForNewSignal();
LoadMlnlFromWav(pathToLoad, &app.annotations);
TraceLog(LOG_INFO, "File loaded successfully");
}
}
if (!fileLoaded) TraceLog(LOG_INFO, "Press 'O' for file browser or drag & drop WAV file");
while (!WindowShouldClose())
// ---- 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)
{
#ifdef __EMSCRIPTEN__
// Track the browser viewport (fill + reflow on resize, like desktop).
@@ -362,6 +762,7 @@ int main(int argc, char* argv[])
if (isWav && FileExists(dropped.paths[0])) {
if (LoadWavFile(dropped.paths[0], &app.signal)) {
ResetForNewSignal();
LoadMlnlFromWav(dropped.paths[0], &app.annotations);
}
}
}
@@ -606,7 +1007,12 @@ int main(int argc, char* argv[])
Vector2 mousePos = GetMousePosition();
// Calculate divider screen position (for hover detection)
float dividerScreenY = selTopMargin + selSpectroHeight;
// Divider is drawn at the BOTTOM of the spectrogram viewport. With the
// timeline lane occupying space above the viewport, viewBounds.y was
// shifted down — so the divider's actual screen Y is `viewBounds.y +
// viewBounds.height`, NOT `topMargin + spectroHeight` (those two used
// to be equal before the lane existed).
float dividerScreenY = selBounds.y + selBounds.height;
bool mouseNearDivider = mousePos.y >= (dividerScreenY - 5) && mousePos.y <= (dividerScreenY + 5) &&
mousePos.x >= selBounds.x && mousePos.x <= selBounds.x + selBounds.width;
@@ -817,6 +1223,7 @@ int main(int argc, char* argv[])
app.isBgProcessing = false;
app.loadingPhase = 0;
SaveToCache();
if (app.autocropPending) { ApplyAutoCrop(); app.autocropPending = false; }
#else
if (app.loadingPhase == 0) {
// Initialize STFT once
@@ -855,6 +1262,11 @@ int main(int argc, char* argv[])
app.stft.numSegments, app.skipFactor);
// Save the overview result to cache (will be overwritten when full-res completes)
SaveToCache();
// Run auto-crop now that we have both annotations (loaded right
// after LoadWavFile) AND an STFT (for the energy fallback).
// Gated on autocropPending so an FFT-size change (which routes
// through the same loadingPhase=2 block) doesn't re-fire it.
if (app.autocropPending) { ApplyAutoCrop(); app.autocropPending = false; }
}
#endif // __EMSCRIPTEN__
}
@@ -950,9 +1362,14 @@ int main(int argc, char* argv[])
int visibleEndX = (int)(app.view.end * imgWidth);
int visibleWidth = visibleEndX - visibleStartX;
// Frequency: 0 = bottom of image (bin 0), 1 = top of image (bin max)
int visibleStartY = (int)((1.0f - app.view.freqEnd) * imgHeight);
int visibleEndY = (int)((1.0f - app.view.freqStart) * imgHeight);
// Frequency: 0 = bottom of image (bin 0), 1 = top of image (bin max).
// The display-crop slider maps view.freqStart/End=1.0 to a fraction
// of the texture's height < 1.0, effectively zooming the freq axis
// so the cropped band fills the viewport while leaving the source
// texture untouched.
float cropFrac = DisplayFreqFraction();
int visibleStartY = (int)((1.0f - app.view.freqEnd * cropFrac) * imgHeight);
int visibleEndY = (int)((1.0f - app.view.freqStart * cropFrac) * imgHeight);
int visibleHeight = visibleEndY - visibleStartY;
// Invalidate cache if view changed or texture not valid
@@ -1054,18 +1471,25 @@ int main(int argc, char* argv[])
if (IsMouseButtonReleased(MOUSE_LEFT_BUTTON)) { draggingH = false; draggingV = false; }
if (app.showGrid) DrawSpectrogramGrid(viewBounds, 10, 8, Fade(GRAY, 0.3f));
// Timeline lane sits above the spectrogram. Drawn before the
// overlays so its hover/selection state is set for the same frame.
if (L.timelineHeight > 0) DrawTimeline(L.timelineBounds);
DrawAnnotations(viewBounds);
DrawSelection(viewBounds);
DrawSelectionDrag(viewBounds);
DrawMarkers(viewBounds);
DrawPlayhead(viewBounds);
DrawLabels(viewBounds);
if (!UiModalOpen()) DrawCursorReadout(viewBounds);
if (!UiModalOpen() && app.hoveredEvent < 0 && app.hoveredTimelineEvent < 0)
DrawCursorReadout(viewBounds);
DrawSpectrumPanel(viewBounds);
float maxFreq = (float)app.signal.sampleRate / 2.0f;
float maxFreq = EffectiveMaxFreqHz();
float freqMin = app.view.freqStart * maxFreq;
float freqMax = app.view.freqEnd * maxFreq;
// Pin to the top margin so the timeline lane (which lives between
// the banner and the spectrogram) doesn't shove the banner down.
DrawTextScaled(TextFormat("Freq: %.0f-%.0f Hz", freqMin, freqMax),
viewBounds.x, viewBounds.y - 30, 20, LIGHTGRAY);
viewBounds.x, topMargin - 30, 20, LIGHTGRAY);
// Draw waveform scope view underneath the spectrogram
if (app.showScope && app.loaded && app.signal.samples != NULL) {
@@ -1088,6 +1512,11 @@ int main(int argc, char* argv[])
} else {
DrawScopeView(&app.scopeView, -1.0f);
}
// Echo the annotation overlay onto the scope so selecting an
// event in the timeline highlights both surfaces at once.
DrawAnnotationsOnScope((Rectangle){
(float)app.scopeView.x, (float)app.scopeView.y,
(float)app.scopeView.width, (float)app.scopeView.height });
// Scope label, tucked inside the top-left so it clears the time
// axis labels and scrollbar that sit in the band above the scope.
DrawTextScaled("Waveform", viewBounds.x + 4 * renderScale, app.scopeView.y + 3 * renderScale,
@@ -1143,6 +1572,10 @@ int main(int argc, char* argv[])
// Draw file browser on top (if active)
if (app.showFileBrowser) DrawFileBrowser();
// Auto-crop notice modal — drawn below About so About still wins if
// both happened to be up at once (shouldn't happen in practice).
DrawAutocropNotice();
// About / help dialog (topmost)
DrawAboutDialog();
@@ -1165,8 +1598,36 @@ 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...");
if (mainFont.texture.id != 0) UnloadFont(mainFont);
if (AudioPlaybackSound.frameCount != 0) UnloadSound(AudioPlaybackSound);
@@ -1178,8 +1639,9 @@ int main(int argc, char* argv[])
FreeBrowserFiles();
FreeAllCacheEntries(&app.fftCache);
free(app.reassignBuffer);
FreeMlnl(&app.annotations);
FreeSignal(&app.signal);
CloseAudioDevice();
CloseWindow();
return 0;
return headlessRc;
}