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:
+78
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user