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
+168
View File
@@ -126,6 +126,7 @@ static void LoadSelectedFile(void)
NavigateToDirectory(app.browserFiles[app.browserSelected]);
} else if (FileExists(filePath) && LoadWavFile(filePath, &app.signal)) {
ResetForNewSignal();
LoadMlnlFromWav(filePath, &app.annotations);
app.showFileBrowser = false;
TraceLog(LOG_INFO, "Loaded: %s", filePath);
}
@@ -427,6 +428,36 @@ void DrawSidebar(void)
DrawPanelBox(gridCheck, app.showGrid ? BLUE : DARKGRAY, WHITE);
DrawTextScaled("Show Grid", x + 25 * scale, y + 2 * scale, 14, LIGHTGRAY); y += 28 * scale;
// Display freq crop — only meaningful when a signal is loaded.
if (app.loaded && app.signal.sampleRate > 0) {
float nyq = app.signal.sampleRate * 0.5f;
float curr = EffectiveMaxFreqHz();
DrawTextScaled(TextFormat("Display max: %.0f Hz", curr), x, y, 13, LIGHTGRAY);
// "Auto" button to re-run the auto-crop heuristic (annotations first,
// then energy). Same logic that runs once on file load.
Rectangle autoBtn = { x + sidebarWidth - 58 * scale, y - 2 * scale,
48 * scale, 16 * scale };
if (Clicked(autoBtn)) ApplyAutoCrop();
DrawPanelBox(autoBtn, (Color){ 60, 50, 80, 255 }, (Color){ 160, 130, 200, 255 });
DrawTextScaled("auto", autoBtn.x + 12 * scale, autoBtn.y + 2 * scale, 10, WHITE);
y += 18 * scale;
Rectangle cropSlider = { x, y, sidebarWidth - 10 * scale, 14 * scale };
float frac = curr / nyq;
DrawSlider(cropSlider, frac);
if (UpdateSlider(cropSlider, &frac)) {
// Don't let the user crop to a useless sliver — keep at least
// ~2% of the band visible (250 Hz on a 12 kHz file).
if (frac < 0.02f) frac = 0.02f;
app.displayMaxFreqHz = frac * nyq;
// Reset view to show the full cropped range — otherwise a prior
// zoom-into-half-band would magnify even further after a crop.
app.view.freqStart = 0.0f;
app.view.freqEnd = 1.0f;
app.visibleTextureValid = false;
}
y += 22 * scale;
}
// Analysis toggles: marker/ruler tool and spectrum-slice (PSD) panel.
Rectangle markerBtn = { x, y, sidebarWidth - 10 * scale, 24 * scale };
if (Clicked(markerBtn)) app.markerMode = !app.markerMode;
@@ -444,6 +475,71 @@ void DrawSidebar(void)
specBtn.x + 10 * scale, specBtn.y + 5 * scale, 13, WHITE);
y += 30 * scale;
// ---- mLnL annotations panel — only when the loaded file has any ----
if (app.annotations.loaded && app.annotations.eventCount > 0) {
// Left half = master toggle. Right edge = small expand chevron that
// reveals per-kind checkboxes. The chevron is also clickable when the
// master toggle is OFF so the user can configure what to show before
// re-enabling overlays.
Rectangle annBtn = { x, y, (sidebarWidth - 10 * scale) - 28 * scale, 24 * scale };
Rectangle annExp = { annBtn.x + annBtn.width + 2 * scale, y, 26 * scale, 24 * scale };
if (Clicked(annBtn)) app.showAnnotations = !app.showAnnotations;
if (Clicked(annExp)) app.annotationsExpanded = !app.annotationsExpanded;
DrawPanelBox(annBtn,
app.showAnnotations ? (Color){ 70, 40, 90, 255 } : (Color){ 50, 50, 60, 255 },
app.showAnnotations ? (Color){ 200, 160, 255, 255 } : GRAY);
DrawTextScaled(app.showAnnotations ? "Annotations: ON" : "Annotations: off",
annBtn.x + 10 * scale, annBtn.y + 5 * scale, 13, WHITE);
DrawPanelBox(annExp, (Color){ 50, 50, 60, 255 }, GRAY);
DrawTextScaled(app.annotationsExpanded ? "v" : ">",
annExp.x + 9 * scale, annExp.y + 5 * scale, 13, WHITE);
y += 28 * scale;
if (app.annotationsExpanded) {
// Two opacity sliders: the spectrogram overlay is drawn at the
// "Base" alpha by default, and bumps to "Highlight" for any event
// that's hovered or selected in the timeline lane. Outlines and
// labels scale together with the fill so the whole overlay fades
// as one unit.
DrawTextScaled(TextFormat("Base: %d%%", (int)(app.annotationOpacityBase * 100.0f)),
x + 8 * scale, y, 12, LIGHTGRAY);
y += 16 * scale;
Rectangle baseSlider = { x + 8 * scale, y, sidebarWidth - 26 * scale, 12 * scale };
DrawSlider(baseSlider, app.annotationOpacityBase);
UpdateSlider(baseSlider, &app.annotationOpacityBase);
y += 20 * scale;
DrawTextScaled(TextFormat("Highlight: %d%%", (int)(app.annotationOpacityHover * 100.0f)),
x + 8 * scale, y, 12, LIGHTGRAY);
y += 16 * scale;
Rectangle hovSlider = { x + 8 * scale, y, sidebarWidth - 26 * scale, 12 * scale };
DrawSlider(hovSlider, app.annotationOpacityHover);
UpdateSlider(hovSlider, &app.annotationOpacityHover);
y += 22 * scale;
// Indented checkbox per kind actually present in this file. Order:
// walk the enum so the menu order is stable across files.
for (int k = 0; k < MLNL_KIND_MAX; k++) {
if (!app.annotations.kindPresent[k]) continue;
Rectangle cb = { x + 8 * scale, y + 2 * scale, 14 * scale, 14 * scale };
if (Clicked(cb)) app.annotationKindEnabled[k] = !app.annotationKindEnabled[k];
DrawPanelBox(cb, app.annotationKindEnabled[k] ? (Color){ 140, 100, 200, 255 } : DARKGRAY,
LIGHTGRAY);
DrawTextScaled(MlnlKindName((MlnlKind)k),
cb.x + 22 * scale, cb.y - 1 * scale, 12, LIGHTGRAY);
y += 18 * scale;
}
y += 4 * scale;
}
if (app.annotations.truncated) {
DrawTextScaled("(file truncated)", x + 4 * scale, y, 11,
(Color){ 255, 180, 180, 255 });
y += 16 * scale;
}
y += 6 * scale;
}
// File loading
DrawTextScaled("File:", x, y, 14, LIGHTGRAY); y += 20 * scale;
Rectangle fileButton = { x, y, sidebarWidth - 10 * scale, 25 * scale };
@@ -649,3 +745,75 @@ void DrawAboutDialog(void)
// Note: opening/closing is handled in the main input loop (not here) so the
// same click/keypress that opens the dialog can't immediately close it.
}
// ===== Auto-crop notice splash =====
//
// Raised by ApplyAutoCrop when the view actually shrank. Modal: routes
// through UiModalOpen() so the spectrogram doesn't accept clicks underneath.
// Two buttons:
// [Uncrop] — restore full freq axis and full-duration view
// [OK] — dismiss; keep the cropped view
// Esc closes (= OK). Clicks outside the panel do nothing (avoids losing the
// crop by missing a button by a few px).
void DrawAutocropNotice(void)
{
if (!app.autocropNoticeActive) return;
float scale = GetUIScale();
int sw = GetScreenWidth();
int sh = GetScreenHeight();
DrawRectangle(0, 0, sw, sh, Fade(BLACK, 0.55f));
float pw = 460 * scale;
float ph = 170 * scale;
Rectangle panel = { (sw - pw) * 0.5f, (sh - ph) * 0.5f, pw, ph };
DrawRectangleRec(panel, (Color){ 30, 30, 40, 255 });
DrawRectangleLinesEx(panel, 2, (Color){ 160, 130, 200, 255 });
DrawTextScaled("Auto-crop applied",
panel.x + 20 * scale, panel.y + 16 * scale, 16,
(Color){ 200, 170, 240, 255 });
// Body: word-wrap not needed for the short message ApplyAutoCrop builds.
DrawTextScaled(app.autocropNoticeMsg,
panel.x + 20 * scale, panel.y + 50 * scale, 13, LIGHTGRAY);
float btnW = 110 * scale, btnH = 32 * scale;
float btnY = panel.y + ph - btnH - 16 * scale;
Rectangle uncropBtn = { panel.x + 20 * scale, btnY, btnW, btnH };
Rectangle okBtn = { panel.x + pw - btnW - 20*scale, btnY, btnW, btnH };
bool uncropHover = CheckCollisionPointRec(GetMousePosition(), uncropBtn);
bool okHover = CheckCollisionPointRec(GetMousePosition(), okBtn);
DrawPanelBox(uncropBtn,
uncropHover ? (Color){ 100, 60, 60, 255 } : (Color){ 70, 40, 40, 255 },
(Color){ 230, 160, 160, 255 });
DrawTextScaled("Uncrop",
uncropBtn.x + btnW * 0.5f - MeasureTextScaled("Uncrop", 14) * 0.5f,
uncropBtn.y + 8 * scale, 14, WHITE);
DrawPanelBox(okBtn,
okHover ? (Color){ 60, 90, 60, 255 } : (Color){ 40, 70, 40, 255 },
(Color){ 160, 220, 160, 255 });
DrawTextScaled("OK",
okBtn.x + btnW * 0.5f - MeasureTextScaled("OK", 14) * 0.5f,
okBtn.y + 8 * scale, 14, WHITE);
DrawTextScaled("Esc or Enter = OK", panel.x + pw - 156 * scale,
panel.y + ph - 14 * scale, 10, GRAY);
// Hit handling. Esc/Enter dismiss (keep crop). Uncrop restores full view.
if (IsKeyPressed(KEY_ESCAPE) || IsKeyPressed(KEY_ENTER)) {
app.autocropNoticeActive = false;
}
if (uncropHover && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
app.displayMaxFreqHz = 0.0f;
app.view.start = 0.0f; app.view.end = 1.0f;
app.view.freqStart = 0.0f; app.view.freqEnd = 1.0f;
app.visibleTextureValid = false;
app.autocropNoticeActive = false;
} else if (okHover && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
app.autocropNoticeActive = false;
}
}