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
+78 -1
View File
@@ -6,6 +6,7 @@
#include "raylib.h"
#include "utils.h" // AudioSignal, SignalStats
#include "primitives.h" // ScopeView, WaveformData
#include "mlnl.h" // MlnlAnnotations
#include <stdbool.h>
#include <math.h>
@@ -233,6 +234,47 @@ typedef struct {
bool isDividing; // True while user is dragging the divider
Vector2 dividerStartPos; // Mouse position when started dividing
float dividerStartY; // Spectro height when started dividing
// Display-side frequency crop. Caps the displayed frequency axis at this
// Hz value — purely a visualization concern (signal data, STFT, audio
// playback are unaffected). 0 = no crop, use full Nyquist. The crop is
// automatically clamped to the current signal's Nyquist by the helper
// below, so a 3 kHz crop is harmless when loading a 2 kHz-sample file.
// Persists across loads so a user analyzing mLink (≤3 kHz) doesn't have
// to re-set it after every file open.
float displayMaxFreqHz;
// True when a fresh signal has loaded and is waiting for ApplyAutoCrop to
// run (after the STFT exists, so the energy fallback can compute). Set by
// ResetForNewSignal; cleared by the loadingPhase=2 hook once autocrop has
// run, so an FFT-size change (same loadingPhase path) won't retrigger.
bool autocropPending;
// Notice splash shown when ApplyAutoCrop actually shrank the view. Modal:
// user dismisses with "OK" (keep crop) or "Uncrop" (restore full view).
bool autocropNoticeActive;
char autocropNoticeMsg[256];
// Optional mLnL annotations parsed from the loaded WAV (empty if the file
// doesn't carry the chunk). The annotations overlay has two surfaces:
// 1. A faint always-on draw on the spectrogram (alpha = opacityBase).
// 2. A "timeline lane" above the spectrogram for browsing events;
// hover/click in the lane bumps the matching spectrogram overlay to
// opacityHover so the user can find/inspect specific events without
// the overlay drowning the underlying signal.
MlnlAnnotations annotations;
int hoveredEvent; // spectrogram-cursor hit (-1 = none); used for tooltip
bool showAnnotations; // master on/off
bool annotationsExpanded; // sidebar dropdown open (per-kind checkboxes etc.)
bool annotationKindEnabled[MLNL_KIND_MAX]; // per-kind visibility (filters both surfaces)
float annotationOpacityBase; // 0..1 — quiet always-on alpha for spectrogram overlay
float annotationOpacityHover; // 0..1 — alpha for hovered/selected events
// Timeline lane state. The lane is rendered between the freq-range banner
// and the spectrogram pixels. Collapsed = single-row sparkline; expanded =
// one row per kind currently enabled in the file.
bool timelineExpanded;
int hoveredTimelineEvent; // -1 = none; event index hovered in the lane
int selectedAnnotation; // -1 = none; persistent selection from a lane click
} SpectrogramApp;
// ============================================================================
@@ -248,11 +290,21 @@ extern Font mainFont;
// (defined in spectrogram.c; used by every load path).
void ResetForNewSignal(void);
// Auto-crop the display freq axis and time view to "frequencies/times of
// interest" using whichever source has high confidence:
// 1) mLnL annotations — max(f_hi)+headroom for freq, span(t_start..t_end)+pad for time
// 2) STFT energy heuristic — cumulative-energy threshold for freq, activity envelope for time
// A no-op when neither source meets the confidence test (e.g. signal genuinely
// uses most of the band/timeline). Modifies displayMaxFreqHz, view.start/end,
// and invalidates the texture cache. Called automatically after STFT init on
// every file load; can also be re-run from the sidebar button.
void ApplyAutoCrop(void);
// True when a modal overlay owns input; normal spectrogram/keyboard interaction
// is gated off while this is the case. Add new overlays here in one place.
static inline bool UiModalOpen(void)
{
return app.showFileBrowser || app.showAbout;
return app.showFileBrowser || app.showAbout || app.autocropNoticeActive;
}
// Reset the box selection to the full signal (the "no selection" state).
@@ -262,6 +314,31 @@ static inline void ClearSelection(void)
app.sel.freqStart = 0.0f; app.sel.freqEnd = 1.0f;
}
// Effective top of the displayed frequency axis (Hz). Capped at the actual
// signal Nyquist so the crop never tries to show frequencies that aren't in
// the data. All DISPLAY-side code paths (labels, banner, annotation freq
// mapping, texture sampling fraction) should reach the frequency axis through
// this helper instead of computing sampleRate*0.5 directly. Data-side math
// (STFT bin spacing, PSD, audio filtering) keeps using the true Nyquist.
static inline float EffectiveMaxFreqHz(void)
{
if (app.signal.sampleRate <= 0) return 1.0f;
float nyq = app.signal.sampleRate * 0.5f;
if (app.displayMaxFreqHz > 0.0f && app.displayMaxFreqHz < nyq) return app.displayMaxFreqHz;
return nyq;
}
// Fraction of the texture's full frequency axis that should be visible (0..1).
// Used by the spectrogram's texture sub-image extraction: a value of 0.5 means
// "the visible window's freqEnd=1.0 corresponds to the texture's mid-row".
static inline float DisplayFreqFraction(void)
{
if (app.signal.sampleRate <= 0) return 1.0f;
float nyq = app.signal.sampleRate * 0.5f;
if (nyq <= 0.0f) return 1.0f;
return EffectiveMaxFreqHz() / nyq;
}
// ============================================================================
// Keymap — single source of truth for global key bindings.
// The dispatcher (DispatchKeymap in spectrogram.c) runs every entry whose