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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user