Files
rspektrum/src/spectrogram_types.h
T
tyler c4687ce80d fix: track the playhead against the region actually playing
PlaySelectedRegion copies, bandpasses and normalises the selected span up
front, so the audio in flight is fixed the moment Space is pressed. The
playhead, though, was drawn against the live app.sel and its duration
re-derived from the live selection — so moving or resizing the selection
mid-playback made the marker jump, overrun, or scale against a region
that had nothing to do with what was audible.

Snapshot the played region (playSelStart/playSelEnd) and take the duration
from the buffer's own sample count rather than app.signal.duration, which
is derived pre-mono-downmix and disagrees for stereo files. The playhead
and the scope cursor both read the snapshot; playheadT is clamped at 1.

Also: a sub-threshold click inside an existing selection no longer clears
it. Silently resetting to full range there changes what Space plays, which
is surprising when the click was an aborted drag. A click on empty space
still resets as before.

The *semantics* of editing a selection during playback remain undefined —
whether audio should follow the box, stop, or keep going as it does now.
This commit only makes the marker honest about what is coming out of the
speakers. Recorded in known_bugs.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8ZWfr5XZyyDttvkhJUgHN
2026-08-12 00:19:02 -07:00

399 lines
16 KiB
C

// spectrogram_types.h - Shared types, constants, globals, and small math helpers.
// This is the "spine" header included by every module.
#ifndef SPECTROGRAM_TYPES_H
#define SPECTROGRAM_TYPES_H
#include "raylib.h"
#include "utils.h" // AudioSignal, SignalStats
#include "primitives.h" // ScopeView, WaveformData
#include "mlnl.h" // MlnlAnnotations
#include <stdbool.h>
#include <math.h>
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
#ifndef CYAN
#define CYAN (Color){ 0, 255, 255, 255 }
#endif
// ============================================================================
// Configuration
// ============================================================================
#define FFT_SIZE_DEFAULT 2048
#define FFT_SIZE_MAX 2048
#define FFT_SIZE_MIN 128
#define HOP_RATIO 4 // FFT_SIZE / HOP_SIZE = 4 means 75% overlap
#define MAX_SAMPLE_RATE 48000
#define LOUDNESS_FLOOR_DB -80.0f
// Base resolution for proportional UI scaling (see GetUIScale in render.c)
#define BASE_WIDTH 1280
#define BASE_HEIGHT 800
#define FFT_CACHE_SIZE 4
// ============================================================================
// Data Structures
// ============================================================================
typedef enum {
COLORMAP_GRAYS = 0,
COLORMAP_INFERNO,
COLORMAP_VIRIDIS,
COLORMAP_PLASMA,
COLORMAP_HOT,
COLORMAP_COOL,
COLORMAP_COUNT
} ColormapType;
// How the colorizer maps amplitude to brightness:
// - RELATIVE: ceiling tracks the signal peak; floor sits dynRangeDb below it.
// - ABSOLUTE: fixed dBFS scale (0 dBFS ceiling = full scale, absolute floor).
typedef enum {
SCALE_RELATIVE = 0,
SCALE_ABSOLUTE
} AmplitudeScaleMode;
typedef struct {
float frequency;
float amplitude;
float phase;
} FrequencyData;
typedef struct {
FrequencyData* spectrum;
FrequencyData* derivativeSpectrum; // STFT with derivative window (for synchrosqueezing)
int numBins;
int sampleOffset;
int sampleCount;
} StftSegment;
typedef struct {
StftSegment* segments;
int numSegments;
int sampleRate;
int totalSamples;
bool useHannWindow;
} StftResult;
typedef struct {
int fftSize;
StftResult result;
int accessOrder; // lower = more recently accessed
} FFTCacheEntry;
typedef struct {
FFTCacheEntry entries[FFT_CACHE_SIZE];
int count;
int nextOrder;
} FFTSizeCache;
// The time+frequency box selection and its drag/move interaction state.
// All coordinates are 0-1 normalized. A "box-select" drags out a new box;
// a "move" drags an existing box around.
typedef struct {
float timeStart, timeEnd; // selected time span
float freqStart, freqEnd; // selected frequency span
bool isTimeSelecting; // dragging out a new time span
bool isFreqSelecting; // dragging out a new frequency span
Vector2 selectStartPos; // mouse pos when a box-select began (min-drag check)
bool isDragging; // moving an existing selection box
Vector2 dragStartPos; // mouse pos when the move began
float dragTimeStart; // selection start time when the move began
float dragFreqStart; // selection freq start when the move began
} Selection;
// Two-point ruler for measuring deltas on the spectrogram (Δt, Δf, baud, drift).
// Both points are 0-1 normalized: t over the whole signal, f as a fraction of
// Nyquist. A press-drag-release drops A at press and B at release; the readout
// stays on screen until cleared, so you can re-drag to re-measure.
typedef struct {
bool active; // a measurement exists (drawn + read out)
bool dragging; // mid-drag, placing point B
float t0, f0; // point A
float t1, f1; // point B
} MarkerTool;
// The visible window into the spectrogram (time + frequency), all 0-1
// normalized, plus the range captured at the start of a pan drag.
typedef struct {
float start, end; // visible time range
float freqStart, freqEnd; // visible freq range (0 = 0 Hz, 1 = Nyquist)
bool isPanning;
float panStart, panEnd; // time range captured when the pan began
float panFreqStart, panFreqEnd; // freq range captured when the pan began
Vector2 panStartPos; // mouse pos when the pan began
} Viewport;
typedef struct {
AudioSignal signal;
StftResult stft;
Image spectrogramImage;
Texture2D spectrogramTexture;
bool loaded;
bool stftComputed;
// Playback state
float playheadT; // 0-1 normalized position within the PLAYING region
float playheadElapsed; // Elapsed seconds since play started
// Snapshot of the region actually handed to the audio device, captured at
// PlaySelectedRegion time. The playhead must be measured against this, not
// against the live app.sel — the user can move or resize the selection while
// audio is still playing, and the marker has to keep tracking the sound
// that's really coming out. playDuration comes from the buffer's own sample
// count / sampleRate, so it can't drift from app.signal.duration (which is
// derived pre-mono-downmix and disagrees for stereo files).
float playSelStart, playSelEnd; // sel.timeStart/End when playback began
float playDuration; // true length of the playing buffer, seconds
// Time + frequency box selection and its drag/move interaction state.
Selection sel;
// Two-point ruler tool. markerMode swaps the LMB-drag gesture from
// box-select to dropping markers; showSpectrum toggles the PSD slice panel.
MarkerTool marker;
bool markerMode;
bool showSpectrum;
// Export settings
float exportScale;
char exportDir[4096];
char exportMessage[256];
float exportMessageTimer; // seconds the export message stays on screen
// Visible viewport (time + frequency) and in-progress pan state.
Viewport view;
// Cached visible texture
Texture2D visibleTexture;
int cachedVisibleStart;
int cachedVisibleEnd;
int cachedVisibleStartY;
int cachedVisibleEndY;
bool visibleTextureValid;
// Display settings. amplitudeFloorDb/CeilingDb are the values the colorizer
// actually uses; they're derived from the mode + the controls below.
float amplitudeFloorDb;
float amplitudeCeilingDb;
AmplitudeScaleMode amplitudeMode;
float dynRangeDb; // RELATIVE mode: dB of range shown below the peak
float absoluteFloorDb; // ABSOLUTE mode: floor in dBFS (ceiling pinned at 0)
ColormapType colormap;
bool showGrid;
int fftSize; // Current FFT size (128-2048)
// Cached synchrosqueezed energy (the expensive reassignment result).
// Reused across dB-floor / colormap changes — only re-colorized, not recomputed.
float* reassignBuffer;
int reassignWidth;
int reassignHeight;
// Overlays
bool showAbout; // About / help dialog
// Sidebar scroll offset (px), for when controls overflow a short window
float sidebarScroll;
// File browser state
bool showFileBrowser;
char browserPath[512];
char** browserFiles;
bool* browserIsDir;
int browserFileCount;
int browserScroll;
int browserSelected;
bool isBrowsing;
// Playback state
bool isPlaying;
bool playbackFinished; // Track if playback completed naturally
// Loading/processing state
int loadingPhase; // 0 = computing STFT, 1 = generating texture
float loadingProgress; // 0.0 to 1.0 overall progress
int currentSTFTSegment; // Which segment we're on for incremental processing
// Adaptive resolution: skipFactor=1 means compute all segments, skipFactor=N
// means compute every Nth segment (faster initial load, overview-only).
// highResFinished tracks whether full-res segments have been computed for
// the current view range.
int skipFactor;
bool highResFinished;
// Background high-res computation state.
// After the overview (skipFactor-strided) loads, missing segments are
// filled in at full resolution in the background while the user is idle.
int bgHighResSeg; // next segment index to compute at high-res
bool bgFinished; // true when all segments are computed at high-res
bool isBgProcessing; // true while background task is actively computing
// FFT size cache — LRU cache of previously computed STFT results.
// When user switches FFT sizes, we check the cache first to avoid
// recomputing. When cache is full, we evict the least-recently-used entry.
FFTSizeCache fftCache;
// Waveform scope view (underneath spectrogram viewport)
ScopeView scopeView;
bool showScope; // Toggle to show/hide scope view
// Scope view divider
float dividerY; // Y position of divider between spectrogram and scope (0-1 normalized)
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;
// ============================================================================
// Global State (defined in spectrogram.c)
// ============================================================================
extern SpectrogramApp app;
extern Sound AudioPlaybackSound;
extern Texture2D colormapTexture;
extern Font mainFont;
// Reset all per-signal state after a new signal is loaded into app.signal
// (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 || app.autocropNoticeActive;
}
// Reset the box selection to the full signal (the "no selection" state).
static inline void ClearSelection(void)
{
app.sel.timeStart = 0.0f; app.sel.timeEnd = 1.0f;
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
// `action` is non-NULL and whose gate passes; the About/Help overlay renders
// the whole table so the on-screen key list can never drift from the bindings.
// Order-sensitive keys (Space, Esc) carry action==NULL and are handled inline
// where their frame ordering matters; they appear here for documentation only.
// ============================================================================
typedef void (*KeyActionFn)(void);
#define KEYGATE_NONE 0u
#define KEYGATE_MODAL 1u // skip while a modal overlay is open (!UiModalOpen())
#define KEYGATE_LOADED 2u // require a loaded signal
#define KEYGATE_STFT 4u // require a computed STFT
typedef struct {
int key; // raylib key code
unsigned gate; // KEYGATE_* bitmask
KeyActionFn action; // NULL = handled inline / documentation-only
const char* label; // shown in the help overlay, e.g. "O", "Home"
const char* help; // description for the help overlay
} KeyBinding;
// Returns the keymap table and its entry count (defined in spectrogram.c).
const KeyBinding* GetKeymap(int* count);
// ============================================================================
// Small math helpers (header-inline so every module can use them)
// ============================================================================
static inline float AmplitudeToDecibels(float amplitude)
{
if (amplitude < 0.0001f) amplitude = 0.0001f;
return 20.0f * log10f(amplitude);
}
static inline float Clamp(float value, float min, float max)
{
if (value < min) return min;
if (value > max) return max;
return value;
}
#endif // SPECTROGRAM_TYPES_H