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:
+485
-23
@@ -91,10 +91,22 @@ typedef struct {
|
||||
float vScrollbarWidth;
|
||||
float topMargin;
|
||||
float bottomMargin;
|
||||
float spectroHeight; // height of the spectrogram (respects the scope divider)
|
||||
Rectangle viewBounds; // the spectrogram drawing area
|
||||
float spectroHeight; // height of the spectrogram (respects the scope divider AND the timeline lane)
|
||||
float timelineHeight; // 0 if no annotations / lane hidden
|
||||
Rectangle viewBounds; // the spectrogram drawing area
|
||||
Rectangle timelineBounds;// the annotations timeline lane (zero-sized if not shown)
|
||||
} Layout;
|
||||
|
||||
// Number of MlnlKind rows that should be visible in the expanded timeline:
|
||||
// kinds present in this file AND not filtered out by the per-kind checkboxes.
|
||||
static int CountVisibleAnnotationKinds(void)
|
||||
{
|
||||
int n = 0;
|
||||
for (int k = 0; k < MLNL_KIND_MAX; k++)
|
||||
if (app.annotations.kindPresent[k] && app.annotationKindEnabled[k]) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
static Layout ComputeLayout(void)
|
||||
{
|
||||
Layout L;
|
||||
@@ -107,12 +119,35 @@ static Layout ComputeLayout(void)
|
||||
L.topMargin = 50 * L.scale;
|
||||
L.bottomMargin = 10 * L.scale;
|
||||
L.spectroHeight = (GetScreenHeight() - L.topMargin - L.bottomMargin - L.labelHeight - L.scrollbarHeight - 10 * L.scale) * ScopeDivider();
|
||||
|
||||
// Timeline lane sits above the spectrogram, eating from spectro height
|
||||
// (not from the scope area). Collapsed is a thin sparkline; expanded grows
|
||||
// by one row per enabled kind. Only present when the file carries
|
||||
// annotations and the master toggle is on.
|
||||
L.timelineHeight = 0;
|
||||
if (app.annotations.loaded && app.annotations.eventCount > 0 && app.showAnnotations) {
|
||||
if (app.timelineExpanded) {
|
||||
int rows = CountVisibleAnnotationKinds();
|
||||
if (rows < 1) rows = 1;
|
||||
L.timelineHeight = (rows * 14.0f + 4.0f) * L.scale;
|
||||
} else {
|
||||
L.timelineHeight = 10.0f * L.scale;
|
||||
}
|
||||
}
|
||||
|
||||
float laneX = L.sidebarWidth + L.freqLabelWidth;
|
||||
float laneW = GetScreenWidth() - L.sidebarWidth - L.freqLabelWidth - L.vScrollbarWidth - 20 * L.scale;
|
||||
L.timelineBounds = (Rectangle){ laneX, L.topMargin, laneW, L.timelineHeight };
|
||||
|
||||
// Spectrogram starts below the lane (with a 2-pixel gap) and shrinks accordingly.
|
||||
float gap = (L.timelineHeight > 0) ? 2.0f * L.scale : 0.0f;
|
||||
L.viewBounds = (Rectangle){
|
||||
L.sidebarWidth + L.freqLabelWidth,
|
||||
L.topMargin,
|
||||
GetScreenWidth() - L.sidebarWidth - L.freqLabelWidth - L.vScrollbarWidth - 20 * L.scale,
|
||||
L.spectroHeight
|
||||
laneX,
|
||||
L.topMargin + L.timelineHeight + gap,
|
||||
laneW,
|
||||
L.spectroHeight - L.timelineHeight - gap
|
||||
};
|
||||
L.spectroHeight = L.viewBounds.height;
|
||||
return L;
|
||||
}
|
||||
|
||||
@@ -160,6 +195,245 @@ void ResetForNewSignal(void)
|
||||
if (app.visibleTexture.id != 0) UnloadTexture(app.visibleTexture);
|
||||
app.visibleTexture = (Texture2D){ 0 };
|
||||
app.visibleTextureValid = false;
|
||||
|
||||
// Drop the previous file's annotations; the caller re-parses from the new
|
||||
// path after this returns (LoadMlnlFromWav needs the source path that
|
||||
// raylib's LoadWave already consumed).
|
||||
FreeMlnl(&app.annotations);
|
||||
app.hoveredEvent = -1;
|
||||
app.hoveredTimelineEvent = -1;
|
||||
app.selectedAnnotation = -1;
|
||||
app.autocropPending = true; // run once when this file's STFT is ready
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Auto-crop: shrink the displayed freq axis + time view to where the data
|
||||
// actually lives. Two independent sources, tried in priority order.
|
||||
// ============================================================================
|
||||
|
||||
// 15% headroom above the highest annotated f_hi keeps event boxes from
|
||||
// touching the top edge; 5% time padding gives breathing room around the
|
||||
// outermost events without pushing them into the corners.
|
||||
#define AUTOCROP_FREQ_HEADROOM 1.15f
|
||||
#define AUTOCROP_TIME_PAD_FRAC 0.05f
|
||||
// Confidence thresholds for the energy heuristic. If the cropped freq band
|
||||
// would still cover >80% of Nyquist, or the cropped time would cover >90%
|
||||
// of the timeline, the signal genuinely uses most of the available range
|
||||
// and we leave the view alone (the crop would only be churn).
|
||||
#define AUTOCROP_FREQ_MAX_FRAC 0.80f
|
||||
#define AUTOCROP_TIME_MAX_FRAC 0.90f
|
||||
// Cumulative energy fraction that defines "where signal lives". 0.99 means
|
||||
// the cropped freq range holds 99% of the spectrogram's total power.
|
||||
#define AUTOCROP_FREQ_ENERGY 0.99
|
||||
// Per-segment activity threshold (fraction of the peak segment's energy).
|
||||
// Anything below this is treated as silence at the timeline edges.
|
||||
#define AUTOCROP_TIME_ACTIVITY 0.01
|
||||
|
||||
// Annotation-driven crop: trusts the producer. Always confident when ≥1
|
||||
// annotation has f_hi or any have a non-zero time span. Returns the computed
|
||||
// crop in the out-params; leaves them at "no crop" values on failure.
|
||||
static bool ComputeAnnotationCrop(float* outFreqMaxHz, float* outViewStart, float* outViewEnd)
|
||||
{
|
||||
*outFreqMaxHz = 0.0f;
|
||||
*outViewStart = 0.0f; *outViewEnd = 1.0f;
|
||||
if (!app.annotations.loaded || app.annotations.eventCount == 0) return false;
|
||||
|
||||
double fMax = 0.0;
|
||||
double tMin = 1e18, tMax = -1e18;
|
||||
bool anyFreq = false, anyTime = false;
|
||||
for (int i = 0; i < app.annotations.eventCount; i++) {
|
||||
const MlnlEvent* e = &app.annotations.events[i];
|
||||
if (e->has_freq && e->f_hi_hz > fMax) { fMax = e->f_hi_hz; anyFreq = true; }
|
||||
if (e->t_end >= e->t_start) {
|
||||
if (e->t_start < tMin) tMin = e->t_start;
|
||||
if (e->t_end > tMax) tMax = e->t_end;
|
||||
anyTime = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (anyFreq) *outFreqMaxHz = (float)(fMax * AUTOCROP_FREQ_HEADROOM);
|
||||
|
||||
if (anyTime && app.signal.duration > 0.0f) {
|
||||
double pad = (tMax - tMin) * AUTOCROP_TIME_PAD_FRAC;
|
||||
double s = tMin - pad, e = tMax + pad;
|
||||
if (s < 0.0) s = 0.0;
|
||||
if (e > app.signal.duration) e = app.signal.duration;
|
||||
if (e > s) {
|
||||
*outViewStart = (float)(s / app.signal.duration);
|
||||
*outViewEnd = (float)(e / app.signal.duration);
|
||||
}
|
||||
}
|
||||
return anyFreq || anyTime;
|
||||
}
|
||||
|
||||
// Energy heuristic: walk the STFT, build per-bin and per-segment energy.
|
||||
// Crop freq if 99% of energy fits below 80% of Nyquist; crop time if the
|
||||
// activity envelope occupies <90% of the timeline. Returns true if at least
|
||||
// one axis was confidently cropped.
|
||||
static bool ComputeEnergyCrop(float* outFreqMaxHz, float* outViewStart, float* outViewEnd)
|
||||
{
|
||||
*outFreqMaxHz = 0.0f;
|
||||
*outViewStart = 0.0f; *outViewEnd = 1.0f;
|
||||
if (app.stft.numSegments < 2) return false;
|
||||
int nbins = 0;
|
||||
for (int s = 0; s < app.stft.numSegments; s++) {
|
||||
if (app.stft.segments[s].spectrum && app.stft.segments[s].numBins > nbins)
|
||||
nbins = app.stft.segments[s].numBins;
|
||||
}
|
||||
if (nbins < 4) return false;
|
||||
|
||||
int nsegs = app.stft.numSegments;
|
||||
double* binE = (double*)calloc((size_t)nbins, sizeof(double));
|
||||
double* segE = (double*)calloc((size_t)nsegs, sizeof(double));
|
||||
if (!binE || !segE) { free(binE); free(segE); return false; }
|
||||
|
||||
double totalE = 0.0, segPeak = 0.0;
|
||||
for (int s = 0; s < nsegs; s++) {
|
||||
if (!app.stft.segments[s].spectrum) continue;
|
||||
int nb = app.stft.segments[s].numBins;
|
||||
for (int b = 0; b < nb; b++) {
|
||||
double a = app.stft.segments[s].spectrum[b].amplitude;
|
||||
double e = a * a;
|
||||
binE[b] += e;
|
||||
segE[s] += e;
|
||||
totalE += e;
|
||||
}
|
||||
if (segE[s] > segPeak) segPeak = segE[s];
|
||||
}
|
||||
|
||||
bool didCrop = false;
|
||||
|
||||
// --- Freq axis: smallest bin whose cumulative energy reaches 99%. ---
|
||||
if (totalE > 0.0) {
|
||||
double thr = totalE * AUTOCROP_FREQ_ENERGY;
|
||||
double cum = 0.0;
|
||||
int cropBin = nbins - 1;
|
||||
for (int b = 0; b < nbins; b++) {
|
||||
cum += binE[b];
|
||||
if (cum >= thr) { cropBin = b; break; }
|
||||
}
|
||||
float fraction = (float)cropBin / (float)(nbins - 1);
|
||||
if (fraction <= AUTOCROP_FREQ_MAX_FRAC) {
|
||||
float nyq = app.signal.sampleRate * 0.5f;
|
||||
*outFreqMaxHz = fraction * nyq * AUTOCROP_FREQ_HEADROOM;
|
||||
didCrop = true;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Time axis: activity envelope at 1% of segment-peak energy. ---
|
||||
if (segPeak > 0.0) {
|
||||
double thr = segPeak * AUTOCROP_TIME_ACTIVITY;
|
||||
int first = -1, last = -1;
|
||||
for (int s = 0; s < nsegs; s++) {
|
||||
if (segE[s] >= thr) { if (first < 0) first = s; last = s; }
|
||||
}
|
||||
if (first >= 0 && last > first) {
|
||||
float coverage = (float)(last - first + 1) / (float)nsegs;
|
||||
if (coverage <= AUTOCROP_TIME_MAX_FRAC) {
|
||||
float s0 = (float)first / (float)nsegs;
|
||||
float s1 = (float)(last + 1) / (float)nsegs;
|
||||
float pad = (s1 - s0) * AUTOCROP_TIME_PAD_FRAC;
|
||||
s0 -= pad; s1 += pad;
|
||||
if (s0 < 0.0f) s0 = 0.0f;
|
||||
if (s1 > 1.0f) s1 = 1.0f;
|
||||
*outViewStart = s0;
|
||||
*outViewEnd = s1;
|
||||
didCrop = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
free(binE); free(segE);
|
||||
return didCrop;
|
||||
}
|
||||
|
||||
void ApplyAutoCrop(void)
|
||||
{
|
||||
if (app.signal.sampleRate <= 0 || app.signal.duration <= 0.0f) return;
|
||||
float nyq = app.signal.sampleRate * 0.5f;
|
||||
|
||||
// Compute BOTH heuristics for BOTH axes, then pick the more focused
|
||||
// result per axis. Annotations can be authoritative for freq (the
|
||||
// producer knows the band) yet wide for time (a single late `control`
|
||||
// marker can span almost the whole file even if signal activity ended
|
||||
// long before) — so we don't tie the time choice to the freq choice.
|
||||
float aFreq = 0.0f, aStart = 0.0f, aEnd = 1.0f;
|
||||
float eFreq = 0.0f, eStart = 0.0f, eEnd = 1.0f;
|
||||
ComputeAnnotationCrop(&aFreq, &aStart, &aEnd);
|
||||
ComputeEnergyCrop(&eFreq, &eStart, &eEnd);
|
||||
|
||||
// ---- Freq axis: smaller cropped max wins. ----
|
||||
// Both candidates are 0 when the source didn't propose a crop; treat
|
||||
// those as "didn't propose" rather than "crop to 0".
|
||||
float freqMax = 0.0f;
|
||||
const char* freqSrc = NULL;
|
||||
if (aFreq > 0.0f && eFreq > 0.0f) {
|
||||
if (eFreq < aFreq) { freqMax = eFreq; freqSrc = "energy"; }
|
||||
else { freqMax = aFreq; freqSrc = "annotations"; }
|
||||
} else if (aFreq > 0.0f) { freqMax = aFreq; freqSrc = "annotations"; }
|
||||
else if (eFreq > 0.0f) { freqMax = eFreq; freqSrc = "energy"; }
|
||||
|
||||
// ---- Time axis: more focused (shorter) range wins. ----
|
||||
// Each source's output is a 0..1 fraction of the signal duration; a
|
||||
// value of [0..1] means "didn't crop". We bias against picking a source
|
||||
// that's effectively the whole timeline.
|
||||
bool aShrunk = (aEnd - aStart) < 0.999f;
|
||||
bool eShrunk = (eEnd - eStart) < 0.999f;
|
||||
float vStart = 0.0f, vEnd = 1.0f;
|
||||
const char* timeSrc = NULL;
|
||||
if (aShrunk && eShrunk) {
|
||||
if ((eEnd - eStart) < (aEnd - aStart)) {
|
||||
vStart = eStart; vEnd = eEnd; timeSrc = "energy";
|
||||
} else {
|
||||
vStart = aStart; vEnd = aEnd; timeSrc = "annotations";
|
||||
}
|
||||
} else if (aShrunk) { vStart = aStart; vEnd = aEnd; timeSrc = "annotations"; }
|
||||
else if (eShrunk) { vStart = eStart; vEnd = eEnd; timeSrc = "energy"; }
|
||||
|
||||
bool freqChanged = (freqMax > 0.0f && freqMax < nyq * 0.99f);
|
||||
bool timeChanged = (timeSrc != NULL);
|
||||
|
||||
if (!freqChanged && !timeChanged) {
|
||||
TraceLog(LOG_INFO, "Auto-crop: no confident source, leaving view alone");
|
||||
return;
|
||||
}
|
||||
|
||||
if (freqChanged) {
|
||||
if (freqMax > nyq) freqMax = nyq;
|
||||
app.displayMaxFreqHz = freqMax;
|
||||
}
|
||||
if (timeChanged) {
|
||||
app.view.start = vStart;
|
||||
app.view.end = vEnd;
|
||||
}
|
||||
// Fit freq view to the cropped band; otherwise a prior zoom would
|
||||
// double-zoom on top of the new crop.
|
||||
app.view.freqStart = 0.0f;
|
||||
app.view.freqEnd = 1.0f;
|
||||
app.visibleTextureValid = false;
|
||||
|
||||
// Splash message — mention per-axis source separately when they diverge
|
||||
// (e.g. freq from annotations, time from energy on a file with a stray
|
||||
// late control event).
|
||||
char freqPart[64] = "", timePart[64] = "";
|
||||
if (freqChanged) snprintf(freqPart, sizeof(freqPart), "0-%.0f Hz", freqMax);
|
||||
if (timeChanged) snprintf(timePart, sizeof(timePart), "%.2f-%.2f s",
|
||||
vStart * app.signal.duration, vEnd * app.signal.duration);
|
||||
char srcPart[80];
|
||||
if (freqChanged && timeChanged && freqSrc && timeSrc && strcmp(freqSrc, timeSrc) != 0) {
|
||||
snprintf(srcPart, sizeof(srcPart), "freq: %s, time: %s", freqSrc, timeSrc);
|
||||
} else {
|
||||
const char* s = freqSrc ? freqSrc : timeSrc;
|
||||
snprintf(srcPart, sizeof(srcPart), "%s", s ? s : "auto");
|
||||
}
|
||||
snprintf(app.autocropNoticeMsg, sizeof(app.autocropNoticeMsg),
|
||||
"View auto-cropped to %s%s%s (%s).",
|
||||
freqPart,
|
||||
(freqChanged && timeChanged) ? ", " : "",
|
||||
timePart,
|
||||
srcPart);
|
||||
app.autocropNoticeActive = true;
|
||||
TraceLog(LOG_INFO, "Auto-crop: %s", app.autocropNoticeMsg);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -234,6 +508,69 @@ static void DispatchKeymap(void)
|
||||
|
||||
int main(int argc, char* argv[])
|
||||
{
|
||||
// ---- Command-line arguments ----
|
||||
// Two modes:
|
||||
// GUI: rspektrum [input.wav]
|
||||
// Headless: rspektrum --render OUT.png INPUT.wav [options]
|
||||
// The headless path computes the spectrogram, draws annotations, writes a
|
||||
// PNG, and exits without ever showing a window (FLAG_WINDOW_HIDDEN keeps a
|
||||
// GL context for rendering but puts nothing on screen).
|
||||
const char* inputArg = NULL; // input WAV (positional)
|
||||
const char* renderOut = NULL; // --render target; non-NULL => headless mode
|
||||
bool headless = false;
|
||||
int annoChoice = -1; // -1 = auto (show if present), 0 = off, 1 = on
|
||||
float annoOpacity = -1.0f; // <0 = keep default; else override resting overlay alpha
|
||||
bool paneOnly = false; // crop to the spectrogram pane (no sidebar/scope)
|
||||
int reqW = 1280, reqH = 800; // headless output size
|
||||
|
||||
for (int i = 1; i < argc; i++) {
|
||||
const char* a = argv[i];
|
||||
if ((strcmp(a, "--render") == 0 || strcmp(a, "-r") == 0) && i + 1 < argc) {
|
||||
renderOut = argv[++i];
|
||||
headless = true;
|
||||
} else if (strcmp(a, "--annotations") == 0 || strcmp(a, "-a") == 0) {
|
||||
annoChoice = 1;
|
||||
} else if (strcmp(a, "--no-annotations") == 0) {
|
||||
annoChoice = 0;
|
||||
} else if (strncmp(a, "--annotation-opacity=", 21) == 0) {
|
||||
annoOpacity = (float)atof(a + 21);
|
||||
} else if (strcmp(a, "--annotation-opacity") == 0 && i + 1 < argc) {
|
||||
annoOpacity = (float)atof(argv[++i]);
|
||||
} else if (strcmp(a, "--pane") == 0) {
|
||||
paneOnly = true;
|
||||
} else if (strcmp(a, "--width") == 0 && i + 1 < argc) {
|
||||
reqW = atoi(argv[++i]);
|
||||
} else if (strcmp(a, "--height") == 0 && i + 1 < argc) {
|
||||
reqH = atoi(argv[++i]);
|
||||
} else if (strcmp(a, "--help") == 0 || strcmp(a, "-h") == 0) {
|
||||
printf(
|
||||
"rspektrum - spectrogram viewer\n\n"
|
||||
"Usage:\n"
|
||||
" rspektrum [input.wav] open the GUI\n"
|
||||
" rspektrum --render OUT.png INPUT.wav [opts] write a PNG headlessly\n\n"
|
||||
"Headless options:\n"
|
||||
" -r, --render OUT.png render a screenshot to OUT.png (no window)\n"
|
||||
" -a, --annotations force the annotation overlay on\n"
|
||||
" --no-annotations force the annotation overlay off\n"
|
||||
" (default: shown when the WAV carries annotations)\n"
|
||||
" --annotation-opacity=V resting overlay alpha 0..1 (default 0.06, faint)\n"
|
||||
" --pane capture only the spectrogram pane (no sidebar/scope)\n"
|
||||
" --width N output width (default 1280)\n"
|
||||
" --height N output height (default 800)\n"
|
||||
" -h, --help show this help\n");
|
||||
return 0;
|
||||
} else if (a[0] != '-') {
|
||||
if (!inputArg) inputArg = a;
|
||||
}
|
||||
}
|
||||
if (reqW < 16) reqW = 1280;
|
||||
if (reqH < 16) reqH = 800;
|
||||
if (annoOpacity > 1.0f) annoOpacity = 1.0f;
|
||||
if (headless && !inputArg) {
|
||||
fprintf(stderr, "rspektrum: --render requires an input WAV file\n");
|
||||
return 2;
|
||||
}
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
// FLAG_WINDOW_HIGHDPI is buggy on the web backend: the Emscripten resize
|
||||
// callback sets the screen size to window.innerWidth, but the GLFW window-
|
||||
@@ -244,9 +581,16 @@ int main(int argc, char* argv[])
|
||||
// resizes the canvas to the window when FLAG_WINDOW_RESIZABLE is set.
|
||||
SetConfigFlags(FLAG_VSYNC_HINT | FLAG_WINDOW_RESIZABLE);
|
||||
#else
|
||||
SetConfigFlags(FLAG_VSYNC_HINT | FLAG_WINDOW_RESIZABLE | FLAG_WINDOW_HIGHDPI);
|
||||
if (headless) {
|
||||
// Hidden window: keeps a GL context for rendering the frame, but
|
||||
// nothing is ever shown. No HIGHDPI so the framebuffer matches the
|
||||
// requested size exactly (LoadImageFromScreen reads it back 1:1).
|
||||
SetConfigFlags(FLAG_WINDOW_HIDDEN);
|
||||
} else {
|
||||
SetConfigFlags(FLAG_VSYNC_HINT | FLAG_WINDOW_RESIZABLE | FLAG_WINDOW_HIGHDPI);
|
||||
}
|
||||
#endif
|
||||
InitWindow(1280, 800, "Spectrogram Viewer");
|
||||
InitWindow(headless ? reqW : 1280, headless ? reqH : 800, "Spectrogram Viewer");
|
||||
SetTargetFPS(60);
|
||||
SetTraceLogLevel(LOG_WARNING); // Suppress INFO texture logs
|
||||
InitAudioDevice();
|
||||
@@ -309,6 +653,18 @@ int main(int argc, char* argv[])
|
||||
}
|
||||
app.isPlaying = false;
|
||||
app.playbackFinished = false;
|
||||
app.displayMaxFreqHz = 0.0f; // 0 = no crop; user sets via sidebar slider
|
||||
app.showAnnotations = true;
|
||||
app.annotationsExpanded = false;
|
||||
app.annotationOpacityBase = 0.06f; // whisper-faint by default — signal wins
|
||||
app.annotationOpacityHover = 0.65f; // pop on hover / selection
|
||||
// CLI override: there's no hover in a headless render, so the resting alpha
|
||||
// governs every overlay — bump it to make annotations read in the PNG.
|
||||
if (annoOpacity >= 0.0f) app.annotationOpacityBase = annoOpacity;
|
||||
app.timelineExpanded = false;
|
||||
app.hoveredTimelineEvent = -1;
|
||||
app.selectedAnnotation = -1;
|
||||
for (int i = 0; i < MLNL_KIND_MAX; i++) app.annotationKindEnabled[i] = true;
|
||||
app.showScope = true;
|
||||
app.dividerY = 0.6f; // Start with 60% spectro, 40% scope
|
||||
app.isDividing = false;
|
||||
@@ -326,27 +682,71 @@ int main(int argc, char* argv[])
|
||||
TraceLog(LOG_INFO, "Spectrogram Viewer initialized");
|
||||
|
||||
bool fileLoaded = false;
|
||||
if (argc > 1) {
|
||||
TraceLog(LOG_INFO, "Loading file from command line: %s", argv[1]);
|
||||
if (inputArg) {
|
||||
TraceLog(LOG_INFO, "Loading file from command line: %s", inputArg);
|
||||
char resolvedPath[8192] = { 0 };
|
||||
|
||||
// If the path doesn't exist as-is, try prepending original dir
|
||||
if (!FileExists(argv[1]) && originalDir[0]) {
|
||||
snprintf(resolvedPath, sizeof(resolvedPath), "%s/%s", originalDir, argv[1]);
|
||||
if (!FileExists(inputArg) && originalDir[0]) {
|
||||
snprintf(resolvedPath, sizeof(resolvedPath), "%s/%s", originalDir, inputArg);
|
||||
TraceLog(LOG_INFO, "Trying prepended path: %s", resolvedPath);
|
||||
}
|
||||
const char* pathToLoad = FileExists(argv[1]) ? argv[1] : resolvedPath;
|
||||
const char* pathToLoad = FileExists(inputArg) ? inputArg : resolvedPath;
|
||||
|
||||
if (FileExists(pathToLoad) && LoadWavFile(pathToLoad, &app.signal)) {
|
||||
fileLoaded = true;
|
||||
ResetForNewSignal();
|
||||
LoadMlnlFromWav(pathToLoad, &app.annotations);
|
||||
TraceLog(LOG_INFO, "File loaded successfully");
|
||||
}
|
||||
}
|
||||
|
||||
if (!fileLoaded) TraceLog(LOG_INFO, "Press 'O' for file browser or drag & drop WAV file");
|
||||
|
||||
while (!WindowShouldClose())
|
||||
// ---- Headless render setup ----
|
||||
// Compute the spectrogram synchronously here; the frame is drawn and
|
||||
// captured at the bottom of the (single-pass) main loop below. headlessRc
|
||||
// gates the loop: a load failure skips it entirely.
|
||||
int headlessRc = 0;
|
||||
char headlessOut[8192] = { 0 };
|
||||
if (headless) {
|
||||
if (!fileLoaded) {
|
||||
fprintf(stderr, "rspektrum: failed to load input WAV '%s'\n", inputArg);
|
||||
headlessRc = 1;
|
||||
} else {
|
||||
// Resolve the output path relative to the launch dir (CWD was
|
||||
// changed to resources/ by SearchAndSetResourceDir).
|
||||
if (renderOut[0] == '/') {
|
||||
snprintf(headlessOut, sizeof(headlessOut), "%s", renderOut);
|
||||
} else {
|
||||
snprintf(headlessOut, sizeof(headlessOut), "%s/%s", originalDir, renderOut);
|
||||
}
|
||||
|
||||
if (annoChoice == 0) app.showAnnotations = false;
|
||||
else if (annoChoice == 1) app.showAnnotations = true;
|
||||
|
||||
// Compute the full-resolution STFT in one shot (no incremental /
|
||||
// background passes — there is no interactive loop to spread them
|
||||
// over). Mirrors the Emscripten single-shot path above.
|
||||
ComputeSTFTInit(&app.signal, &app.stft, app.fftSize);
|
||||
app.skipFactor = 1;
|
||||
ComputeSTFTIncremental(&app.signal, &app.stft, app.fftSize, 0);
|
||||
AutoScaleAmplitude(&app.stft);
|
||||
GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage, &app.spectrogramTexture);
|
||||
app.currentSTFTSegment = app.stft.numSegments;
|
||||
app.bgHighResSeg = app.stft.numSegments;
|
||||
app.stftComputed = true;
|
||||
app.highResFinished = true;
|
||||
app.bgFinished = true;
|
||||
app.isBgProcessing = false;
|
||||
app.loadingPhase = 0;
|
||||
if (app.autocropPending) { ApplyAutoCrop(); app.autocropPending = false; }
|
||||
app.autocropNoticeActive = false; // don't draw the crop splash into the shot
|
||||
app.exportMessage[0] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
while (!WindowShouldClose() && headlessRc == 0)
|
||||
{
|
||||
#ifdef __EMSCRIPTEN__
|
||||
// Track the browser viewport (fill + reflow on resize, like desktop).
|
||||
@@ -362,6 +762,7 @@ int main(int argc, char* argv[])
|
||||
if (isWav && FileExists(dropped.paths[0])) {
|
||||
if (LoadWavFile(dropped.paths[0], &app.signal)) {
|
||||
ResetForNewSignal();
|
||||
LoadMlnlFromWav(dropped.paths[0], &app.annotations);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -606,7 +1007,12 @@ int main(int argc, char* argv[])
|
||||
Vector2 mousePos = GetMousePosition();
|
||||
|
||||
// Calculate divider screen position (for hover detection)
|
||||
float dividerScreenY = selTopMargin + selSpectroHeight;
|
||||
// Divider is drawn at the BOTTOM of the spectrogram viewport. With the
|
||||
// timeline lane occupying space above the viewport, viewBounds.y was
|
||||
// shifted down — so the divider's actual screen Y is `viewBounds.y +
|
||||
// viewBounds.height`, NOT `topMargin + spectroHeight` (those two used
|
||||
// to be equal before the lane existed).
|
||||
float dividerScreenY = selBounds.y + selBounds.height;
|
||||
bool mouseNearDivider = mousePos.y >= (dividerScreenY - 5) && mousePos.y <= (dividerScreenY + 5) &&
|
||||
mousePos.x >= selBounds.x && mousePos.x <= selBounds.x + selBounds.width;
|
||||
|
||||
@@ -817,6 +1223,7 @@ int main(int argc, char* argv[])
|
||||
app.isBgProcessing = false;
|
||||
app.loadingPhase = 0;
|
||||
SaveToCache();
|
||||
if (app.autocropPending) { ApplyAutoCrop(); app.autocropPending = false; }
|
||||
#else
|
||||
if (app.loadingPhase == 0) {
|
||||
// Initialize STFT once
|
||||
@@ -855,6 +1262,11 @@ int main(int argc, char* argv[])
|
||||
app.stft.numSegments, app.skipFactor);
|
||||
// Save the overview result to cache (will be overwritten when full-res completes)
|
||||
SaveToCache();
|
||||
// Run auto-crop now that we have both annotations (loaded right
|
||||
// after LoadWavFile) AND an STFT (for the energy fallback).
|
||||
// Gated on autocropPending so an FFT-size change (which routes
|
||||
// through the same loadingPhase=2 block) doesn't re-fire it.
|
||||
if (app.autocropPending) { ApplyAutoCrop(); app.autocropPending = false; }
|
||||
}
|
||||
#endif // __EMSCRIPTEN__
|
||||
}
|
||||
@@ -950,9 +1362,14 @@ int main(int argc, char* argv[])
|
||||
int visibleEndX = (int)(app.view.end * imgWidth);
|
||||
int visibleWidth = visibleEndX - visibleStartX;
|
||||
|
||||
// Frequency: 0 = bottom of image (bin 0), 1 = top of image (bin max)
|
||||
int visibleStartY = (int)((1.0f - app.view.freqEnd) * imgHeight);
|
||||
int visibleEndY = (int)((1.0f - app.view.freqStart) * imgHeight);
|
||||
// Frequency: 0 = bottom of image (bin 0), 1 = top of image (bin max).
|
||||
// The display-crop slider maps view.freqStart/End=1.0 to a fraction
|
||||
// of the texture's height < 1.0, effectively zooming the freq axis
|
||||
// so the cropped band fills the viewport while leaving the source
|
||||
// texture untouched.
|
||||
float cropFrac = DisplayFreqFraction();
|
||||
int visibleStartY = (int)((1.0f - app.view.freqEnd * cropFrac) * imgHeight);
|
||||
int visibleEndY = (int)((1.0f - app.view.freqStart * cropFrac) * imgHeight);
|
||||
int visibleHeight = visibleEndY - visibleStartY;
|
||||
|
||||
// Invalidate cache if view changed or texture not valid
|
||||
@@ -1054,18 +1471,25 @@ int main(int argc, char* argv[])
|
||||
if (IsMouseButtonReleased(MOUSE_LEFT_BUTTON)) { draggingH = false; draggingV = false; }
|
||||
|
||||
if (app.showGrid) DrawSpectrogramGrid(viewBounds, 10, 8, Fade(GRAY, 0.3f));
|
||||
// Timeline lane sits above the spectrogram. Drawn before the
|
||||
// overlays so its hover/selection state is set for the same frame.
|
||||
if (L.timelineHeight > 0) DrawTimeline(L.timelineBounds);
|
||||
DrawAnnotations(viewBounds);
|
||||
DrawSelection(viewBounds);
|
||||
DrawSelectionDrag(viewBounds);
|
||||
DrawMarkers(viewBounds);
|
||||
DrawPlayhead(viewBounds);
|
||||
DrawLabels(viewBounds);
|
||||
if (!UiModalOpen()) DrawCursorReadout(viewBounds);
|
||||
if (!UiModalOpen() && app.hoveredEvent < 0 && app.hoveredTimelineEvent < 0)
|
||||
DrawCursorReadout(viewBounds);
|
||||
DrawSpectrumPanel(viewBounds);
|
||||
float maxFreq = (float)app.signal.sampleRate / 2.0f;
|
||||
float maxFreq = EffectiveMaxFreqHz();
|
||||
float freqMin = app.view.freqStart * maxFreq;
|
||||
float freqMax = app.view.freqEnd * maxFreq;
|
||||
// Pin to the top margin so the timeline lane (which lives between
|
||||
// the banner and the spectrogram) doesn't shove the banner down.
|
||||
DrawTextScaled(TextFormat("Freq: %.0f-%.0f Hz", freqMin, freqMax),
|
||||
viewBounds.x, viewBounds.y - 30, 20, LIGHTGRAY);
|
||||
viewBounds.x, topMargin - 30, 20, LIGHTGRAY);
|
||||
|
||||
// Draw waveform scope view underneath the spectrogram
|
||||
if (app.showScope && app.loaded && app.signal.samples != NULL) {
|
||||
@@ -1088,6 +1512,11 @@ int main(int argc, char* argv[])
|
||||
} else {
|
||||
DrawScopeView(&app.scopeView, -1.0f);
|
||||
}
|
||||
// Echo the annotation overlay onto the scope so selecting an
|
||||
// event in the timeline highlights both surfaces at once.
|
||||
DrawAnnotationsOnScope((Rectangle){
|
||||
(float)app.scopeView.x, (float)app.scopeView.y,
|
||||
(float)app.scopeView.width, (float)app.scopeView.height });
|
||||
// Scope label, tucked inside the top-left so it clears the time
|
||||
// axis labels and scrollbar that sit in the band above the scope.
|
||||
DrawTextScaled("Waveform", viewBounds.x + 4 * renderScale, app.scopeView.y + 3 * renderScale,
|
||||
@@ -1143,6 +1572,10 @@ int main(int argc, char* argv[])
|
||||
// Draw file browser on top (if active)
|
||||
if (app.showFileBrowser) DrawFileBrowser();
|
||||
|
||||
// Auto-crop notice modal — drawn below About so About still wins if
|
||||
// both happened to be up at once (shouldn't happen in practice).
|
||||
DrawAutocropNotice();
|
||||
|
||||
// About / help dialog (topmost)
|
||||
DrawAboutDialog();
|
||||
|
||||
@@ -1165,8 +1598,36 @@ int main(int argc, char* argv[])
|
||||
}
|
||||
|
||||
EndDrawing();
|
||||
|
||||
// Headless: the frame is now fully rendered. Read it back, optionally
|
||||
// crop to the spectrogram pane, write the PNG, and stop the loop.
|
||||
if (headless) {
|
||||
Image shot = LoadImageFromScreen();
|
||||
if (paneOnly) {
|
||||
// Crop to the spectrogram pane: freq labels + banner + timeline
|
||||
// lane + spectrogram + time-axis labels. Drops sidebar + scope.
|
||||
Layout capL = ComputeLayout();
|
||||
Rectangle vb = capL.viewBounds;
|
||||
float top = capL.topMargin - 30.0f;
|
||||
if (top < 0.0f) top = 0.0f;
|
||||
float left = capL.sidebarWidth;
|
||||
float right = vb.x + vb.width + capL.vScrollbarWidth + 10.0f * capL.scale;
|
||||
float bottom = vb.y + vb.height + capL.labelHeight + 4.0f * capL.scale;
|
||||
ImageCrop(&shot, (Rectangle){ left, top, right - left, bottom - top });
|
||||
}
|
||||
int outW = shot.width, outH = shot.height;
|
||||
bool ok = ExportImage(shot, headlessOut);
|
||||
UnloadImage(shot);
|
||||
if (ok) {
|
||||
printf("Wrote %s (%dx%d)\n", headlessOut, outW, outH);
|
||||
} else {
|
||||
fprintf(stderr, "rspektrum: failed to write '%s'\n", headlessOut);
|
||||
headlessRc = 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
TraceLog(LOG_INFO, "Shutting down...");
|
||||
if (mainFont.texture.id != 0) UnloadFont(mainFont);
|
||||
if (AudioPlaybackSound.frameCount != 0) UnloadSound(AudioPlaybackSound);
|
||||
@@ -1178,8 +1639,9 @@ int main(int argc, char* argv[])
|
||||
FreeBrowserFiles();
|
||||
FreeAllCacheEntries(&app.fftCache);
|
||||
free(app.reassignBuffer);
|
||||
FreeMlnl(&app.annotations);
|
||||
FreeSignal(&app.signal);
|
||||
CloseAudioDevice();
|
||||
CloseWindow();
|
||||
return 0;
|
||||
return headlessRc;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user