feat: spectrum-slice (PSD) panel + two-point marker/ruler tool
Spectrum slice (S): floating panel plotting the time-averaged power spectrum of the current selection (or the visible view when there's no selection). Frequency on X over the region's band, auto-ranged dB on Y, with a peak marker. Backed by ComputePowerSpectrum() in stft.c (mean linear power per bin over the time span). The selection stat panel now biases to the left when this panel is up so the two don't overlap. Marker/ruler tool (M): press-drag-release drops point A and B; the overlay shows crosshairs, a connecting line, and a readout of the ham-useful deltas — Δt, Δf, tone spacing (1/Δt), and drift (Δf/Δt). Marker mode swaps the LMB-drag gesture from box-select to marker drop (Alt/middle still pan); RMB/Esc clear the measurement. Markers reset on new-file load alongside the selection. Both are gated toggles (off by default), wired into the keymap (so they self-document in the About dialog) and the sidebar. Verified headlessly: idle viewport pixel-identical to baseline (AE=0, deterministic); both panels render correctly with sensible numbers. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+200
-8
@@ -390,14 +390,22 @@ static void DrawStatPanel(Rectangle bounds, Rectangle sel)
|
|||||||
float selLeft = sel.x, selRight = sel.x + sel.width;
|
float selLeft = sel.x, selRight = sel.x + sel.width;
|
||||||
float selCenterY = sel.y + sel.height * 0.5f;
|
float selCenterY = sel.y + sel.height * 0.5f;
|
||||||
|
|
||||||
// Prefer the right of the selection; fall back to the left, then clamp.
|
// Normally prefer the right of the selection, falling back to the left. But
|
||||||
float boxX = selRight + 10;
|
// the spectrum panel lives in the top-right, so when it's up bias the other
|
||||||
if (boxX + boxW > bounds.x + bounds.width) {
|
// way to avoid stacking the two boxes on top of each other.
|
||||||
|
float boxX;
|
||||||
|
if (app.showSpectrum) {
|
||||||
boxX = selLeft - boxW - 10;
|
boxX = selLeft - boxW - 10;
|
||||||
if (boxX < bounds.x) boxX = bounds.x;
|
if (boxX < bounds.x) {
|
||||||
}
|
boxX = selRight + 10;
|
||||||
if (boxX + boxW > bounds.x + bounds.width) {
|
if (boxX + boxW > bounds.x + bounds.width) boxX = bounds.x;
|
||||||
boxX = (selLeft > boxW + 20) ? selLeft - boxW - 10 : bounds.x;
|
}
|
||||||
|
} else {
|
||||||
|
boxX = selRight + 10;
|
||||||
|
if (boxX + boxW > bounds.x + bounds.width) {
|
||||||
|
boxX = selLeft - boxW - 10;
|
||||||
|
if (boxX < bounds.x) boxX = bounds.x;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
float boxY = selCenterY - boxH / 2.0f;
|
float boxY = selCenterY - boxH / 2.0f;
|
||||||
if (boxY < bounds.y) boxY = bounds.y;
|
if (boxY < bounds.y) boxY = bounds.y;
|
||||||
@@ -488,7 +496,7 @@ void DrawCursorReadout(Rectangle bounds)
|
|||||||
{
|
{
|
||||||
if (!app.loaded || !app.stftComputed || app.signal.samples == NULL) return;
|
if (!app.loaded || !app.stftComputed || app.signal.samples == NULL) return;
|
||||||
if (app.sel.isTimeSelecting || app.sel.isFreqSelecting || app.sel.isDragging ||
|
if (app.sel.isTimeSelecting || app.sel.isFreqSelecting || app.sel.isDragging ||
|
||||||
app.view.isPanning) return;
|
app.view.isPanning || app.marker.dragging) return;
|
||||||
Vector2 m = GetMousePosition();
|
Vector2 m = GetMousePosition();
|
||||||
if (!CheckCollisionPointRec(m, bounds)) return;
|
if (!CheckCollisionPointRec(m, bounds)) return;
|
||||||
|
|
||||||
@@ -530,6 +538,190 @@ void DrawCursorReadout(Rectangle bounds)
|
|||||||
DrawText(text, (int)bx + 6, (int)by + 4, fontSize, (Color){ 180, 220, 255, 255 });
|
DrawText(text, (int)bx + 6, (int)by + 4, fontSize, (Color){ 180, 220, 255, 255 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Marker / delta ruler
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Map a marker's normalized (t, f) to a screen point inside `bounds`, honoring
|
||||||
|
// the current zoom. Returns false if it falls outside the visible view.
|
||||||
|
static bool MarkerToScreen(Rectangle bounds, float t, float f, Vector2* out)
|
||||||
|
{
|
||||||
|
float vw = app.view.end - app.view.start;
|
||||||
|
float fw = app.view.freqEnd - app.view.freqStart;
|
||||||
|
if (vw <= 0.0f || fw <= 0.0f) return false;
|
||||||
|
float tx = (t - app.view.start) / vw;
|
||||||
|
float fy = (f - app.view.freqStart) / fw;
|
||||||
|
out->x = bounds.x + tx * bounds.width;
|
||||||
|
out->y = bounds.y + bounds.height - fy * bounds.height;
|
||||||
|
return (tx >= -0.05f && tx <= 1.05f && fy >= -0.05f && fy <= 1.05f);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void DrawMarkerCross(Vector2 p, Color c, const char* tag)
|
||||||
|
{
|
||||||
|
DrawLine((int)p.x - 7, (int)p.y, (int)p.x + 7, (int)p.y, c);
|
||||||
|
DrawLine((int)p.x, (int)p.y - 7, (int)p.x, (int)p.y + 7, c);
|
||||||
|
DrawCircleLines((int)p.x, (int)p.y, 4, c);
|
||||||
|
DrawText(tag, (int)p.x + 7, (int)p.y - 14, 10, c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two-point ruler overlay: crosshairs at A and B, a connecting line, and a
|
||||||
|
// readout of the time/frequency deltas plus the ham-useful derived rates
|
||||||
|
// (tone spacing 1/Δt and drift Δf/Δt).
|
||||||
|
void DrawMarkers(Rectangle bounds)
|
||||||
|
{
|
||||||
|
if (!app.markerMode || !app.marker.active || !app.loaded) return;
|
||||||
|
|
||||||
|
Vector2 a, b;
|
||||||
|
bool aIn = MarkerToScreen(bounds, app.marker.t0, app.marker.f0, &a);
|
||||||
|
bool bIn = MarkerToScreen(bounds, app.marker.t1, app.marker.f1, &b);
|
||||||
|
|
||||||
|
BeginScissorMode((int)bounds.x, (int)bounds.y, (int)bounds.width, (int)bounds.height);
|
||||||
|
DrawLineEx(a, b, 1.0f, Fade(ORANGE, 0.8f));
|
||||||
|
if (aIn) DrawMarkerCross(a, ORANGE, "A");
|
||||||
|
if (bIn) DrawMarkerCross(b, (Color){ 255, 180, 80, 255 }, "B");
|
||||||
|
EndScissorMode();
|
||||||
|
|
||||||
|
// Compute deltas in real units.
|
||||||
|
float nyquist = app.signal.sampleRate * 0.5f;
|
||||||
|
float ta = app.marker.t0 * app.signal.duration, tb = app.marker.t1 * app.signal.duration;
|
||||||
|
float fa = app.marker.f0 * nyquist, fb = app.marker.f1 * nyquist;
|
||||||
|
float dt = fabsf(tb - ta);
|
||||||
|
float df = fabsf(fb - fa);
|
||||||
|
|
||||||
|
char lines[6][64];
|
||||||
|
int n = 0;
|
||||||
|
sprintf(lines[n++], "A: %.3fs %.0fHz", ta, fa);
|
||||||
|
sprintf(lines[n++], "B: %.3fs %.0fHz", tb, fb);
|
||||||
|
if (dt >= 1.0f) sprintf(lines[n++], "dt: %.3f s", dt);
|
||||||
|
else if (dt >= 0.001f) sprintf(lines[n++], "dt: %.1f ms", dt * 1000.0f);
|
||||||
|
else sprintf(lines[n++], "dt: %.3f ms", dt * 1000.0f);
|
||||||
|
sprintf(lines[n++], "df: %.1f Hz", df);
|
||||||
|
if (dt > 1e-6f) sprintf(lines[n++], "1/dt: %.2f Hz", 1.0f / dt);
|
||||||
|
if (dt > 1e-6f) sprintf(lines[n++], "slope: %.0f Hz/s", (fb - fa) / dt);
|
||||||
|
|
||||||
|
int fontSize = 10;
|
||||||
|
int maxW = 0;
|
||||||
|
for (int i = 0; i < n; i++) { int w = MeasureText(lines[i], fontSize); if (w > maxW) maxW = w; }
|
||||||
|
int boxW = maxW + 16, boxH = n * 14 + 10;
|
||||||
|
|
||||||
|
// Anchor near B (or A if B is off-view), clamped inside bounds.
|
||||||
|
Vector2 anchor = bIn ? b : a;
|
||||||
|
float bx = anchor.x + 12, by = anchor.y + 12;
|
||||||
|
if (bx + boxW > bounds.x + bounds.width) bx = anchor.x - boxW - 12;
|
||||||
|
if (bx < bounds.x) bx = bounds.x;
|
||||||
|
if (by + boxH > bounds.y + bounds.height) by = bounds.y + bounds.height - boxH;
|
||||||
|
if (by < bounds.y) by = bounds.y;
|
||||||
|
|
||||||
|
DrawRectangle((int)bx, (int)by, boxW, boxH, (Color){ 0, 0, 0, 210 });
|
||||||
|
DrawRectangleLines((int)bx, (int)by, boxW, boxH, Fade(ORANGE, 0.7f));
|
||||||
|
for (int i = 0; i < n; i++)
|
||||||
|
DrawText(lines[i], (int)bx + 8, (int)by + 6 + i * 14, fontSize, (Color){ 255, 210, 160, 255 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Spectrum slice (averaged PSD of the selection / view)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Floating panel plotting the time-averaged power spectrum over frequency.
|
||||||
|
// Region: the selection box if one exists, otherwise the visible view. The
|
||||||
|
// frequency axis spans that band; the curve is power in dB (auto-ranged).
|
||||||
|
void DrawSpectrumPanel(Rectangle bounds)
|
||||||
|
{
|
||||||
|
if (!app.showSpectrum || !app.loaded || !app.stftComputed) return;
|
||||||
|
if (app.stft.numSegments <= 0) return;
|
||||||
|
|
||||||
|
bool hasSel = (app.sel.timeStart > 0.001f || app.sel.timeEnd < 0.999f ||
|
||||||
|
app.sel.freqStart > 0.001f || app.sel.freqEnd < 0.999f);
|
||||||
|
float t0 = hasSel ? app.sel.timeStart : app.view.start;
|
||||||
|
float t1 = hasSel ? app.sel.timeEnd : app.view.end;
|
||||||
|
float f0 = hasSel ? app.sel.freqStart : app.view.freqStart;
|
||||||
|
float f1 = hasSel ? app.sel.freqEnd : app.view.freqEnd;
|
||||||
|
if (f1 < f0) { float t = f0; f0 = f1; f1 = t; }
|
||||||
|
|
||||||
|
int maxBins = FFT_SIZE_MAX / 2 + 1;
|
||||||
|
float* power = (float*)malloc(maxBins * sizeof(float));
|
||||||
|
if (!power) return;
|
||||||
|
int nbins = ComputePowerSpectrum(&app.stft, t0, t1, power, maxBins);
|
||||||
|
if (nbins < 2) { free(power); return; }
|
||||||
|
|
||||||
|
float nyquist = app.signal.sampleRate * 0.5f;
|
||||||
|
float binHz = nyquist / (float)(nbins - 1);
|
||||||
|
float freqLow = f0 * nyquist, freqHigh = f1 * nyquist;
|
||||||
|
int binLo = (int)floorf(freqLow / binHz);
|
||||||
|
int binHi = (int)ceilf(freqHigh / binHz);
|
||||||
|
if (binLo < 0) binLo = 0;
|
||||||
|
if (binHi > nbins - 1) binHi = nbins - 1;
|
||||||
|
if (binHi <= binLo) { free(power); return; }
|
||||||
|
|
||||||
|
// Auto-range the dB axis over the displayed band.
|
||||||
|
float maxDb = -200.0f, minDb = 200.0f;
|
||||||
|
int peakBin = binLo;
|
||||||
|
for (int b = binLo; b <= binHi; b++) {
|
||||||
|
float db = 10.0f * log10f(power[b] + 1e-20f);
|
||||||
|
if (db > maxDb) { maxDb = db; peakBin = b; }
|
||||||
|
if (db < minDb) minDb = db;
|
||||||
|
}
|
||||||
|
if (maxDb - minDb < 6.0f) minDb = maxDb - 6.0f; // avoid a flat line filling the panel
|
||||||
|
float ceilDb = maxDb + 2.0f, floorDb = minDb - 2.0f;
|
||||||
|
float rangeDb = ceilDb - floorDb;
|
||||||
|
|
||||||
|
// Panel: top-right of the viewport, semi-transparent.
|
||||||
|
float scale = GetUIScale();
|
||||||
|
float panelW = fminf(360.0f * scale, bounds.width * 0.55f);
|
||||||
|
float panelH = 170.0f * scale;
|
||||||
|
Rectangle panel = { bounds.x + bounds.width - panelW - 10.0f * scale, bounds.y + 10.0f * scale,
|
||||||
|
panelW, panelH };
|
||||||
|
DrawRectangleRec(panel, (Color){ 12, 14, 20, 220 });
|
||||||
|
DrawRectangleLinesEx(panel, 1, Fade(SKYBLUE, 0.7f));
|
||||||
|
|
||||||
|
// Plot area inside the panel (leave room for labels).
|
||||||
|
float padL = 6.0f * scale, padR = 6.0f * scale, padT = 18.0f * scale, padB = 14.0f * scale;
|
||||||
|
Rectangle plot = { panel.x + padL, panel.y + padT,
|
||||||
|
panel.width - padL - padR, panel.height - padT - padB };
|
||||||
|
|
||||||
|
DrawText(hasSel ? "Spectrum (selection)" : "Spectrum (view)",
|
||||||
|
(int)(panel.x + 6 * scale), (int)(panel.y + 4 * scale), 10, (Color){ 180, 220, 255, 255 });
|
||||||
|
|
||||||
|
float freqSpan = freqHigh - freqLow;
|
||||||
|
if (freqSpan < 1e-6f) freqSpan = 1e-6f;
|
||||||
|
|
||||||
|
// The curve: one polyline vertex per pixel column, mapping x -> freq -> bin.
|
||||||
|
Vector2 prev = { 0 };
|
||||||
|
bool havePrev = false;
|
||||||
|
for (int px = 0; px <= (int)plot.width; px++) {
|
||||||
|
float fr = freqLow + (px / plot.width) * freqSpan;
|
||||||
|
int b = (int)(fr / binHz + 0.5f);
|
||||||
|
if (b < 0) b = 0;
|
||||||
|
if (b > nbins - 1) b = nbins - 1;
|
||||||
|
float db = 10.0f * log10f(power[b] + 1e-20f);
|
||||||
|
float norm = (db - floorDb) / rangeDb;
|
||||||
|
norm = Clamp(norm, 0.0f, 1.0f);
|
||||||
|
Vector2 cur = { plot.x + px, plot.y + plot.height - norm * plot.height };
|
||||||
|
if (havePrev) DrawLineV(prev, cur, (Color){ 120, 220, 255, 255 });
|
||||||
|
prev = cur;
|
||||||
|
havePrev = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Peak marker + label.
|
||||||
|
float peakFr = peakBin * binHz;
|
||||||
|
float peakX = plot.x + ((peakFr - freqLow) / freqSpan) * plot.width;
|
||||||
|
if (peakX >= plot.x && peakX <= plot.x + plot.width) {
|
||||||
|
DrawLine((int)peakX, (int)plot.y, (int)peakX, (int)(plot.y + plot.height), Fade(YELLOW, 0.5f));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Axis labels: frequency at the ends, peak dB at top-left of the plot.
|
||||||
|
char lbl[32];
|
||||||
|
sprintf(lbl, "%.0f Hz", freqLow);
|
||||||
|
DrawText(lbl, (int)plot.x, (int)(panel.y + panel.height - 12 * scale), 9, GRAY);
|
||||||
|
sprintf(lbl, "%.0f Hz", freqHigh);
|
||||||
|
DrawText(lbl, (int)(plot.x + plot.width - MeasureText(lbl, 9)),
|
||||||
|
(int)(panel.y + panel.height - 12 * scale), 9, GRAY);
|
||||||
|
sprintf(lbl, "pk %.0fHz %.0fdB", peakFr, maxDb);
|
||||||
|
DrawText(lbl, (int)(plot.x + 2), (int)(plot.y + 1), 9, Fade(YELLOW, 0.85f));
|
||||||
|
|
||||||
|
free(power);
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Playhead
|
// Playhead
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ void DrawLabels(Rectangle bounds);
|
|||||||
void DrawSelection(Rectangle bounds);
|
void DrawSelection(Rectangle bounds);
|
||||||
void DrawSelectionDrag(Rectangle bounds);
|
void DrawSelectionDrag(Rectangle bounds);
|
||||||
void DrawCursorReadout(Rectangle bounds);
|
void DrawCursorReadout(Rectangle bounds);
|
||||||
|
void DrawMarkers(Rectangle bounds);
|
||||||
|
void DrawSpectrumPanel(Rectangle bounds);
|
||||||
void DrawPlayhead(Rectangle bounds);
|
void DrawPlayhead(Rectangle bounds);
|
||||||
|
|
||||||
#endif // RENDER_H
|
#endif // RENDER_H
|
||||||
|
|||||||
+41
-2
@@ -128,6 +128,8 @@ void ResetForNewSignal(void)
|
|||||||
app.sel.isDragging = false;
|
app.sel.isDragging = false;
|
||||||
app.sel.isTimeSelecting = false;
|
app.sel.isTimeSelecting = false;
|
||||||
app.sel.isFreqSelecting = false;
|
app.sel.isFreqSelecting = false;
|
||||||
|
app.marker.active = false;
|
||||||
|
app.marker.dragging = false;
|
||||||
app.isDividing = false;
|
app.isDividing = false;
|
||||||
|
|
||||||
// Stop any playback from the previous signal and rewind the playhead.
|
// Stop any playback from the previous signal and rewind the playhead.
|
||||||
@@ -155,6 +157,8 @@ static void ActionToggleAbout(void) { app.showAbout = !app.showAbout; }
|
|||||||
static void ActionToggleFullscreen(void){ ToggleFullscreen(); }
|
static void ActionToggleFullscreen(void){ ToggleFullscreen(); }
|
||||||
static void ActionExport(void) { ExportPNG(&app, app.exportDir); }
|
static void ActionExport(void) { ExportPNG(&app, app.exportDir); }
|
||||||
static void ActionExportWav(void) { ExportSelectionWAV(app.exportDir); }
|
static void ActionExportWav(void) { ExportSelectionWAV(app.exportDir); }
|
||||||
|
static void ActionToggleMarker(void) { app.markerMode = !app.markerMode; }
|
||||||
|
static void ActionToggleSpectrum(void) { app.showSpectrum = !app.showSpectrum; }
|
||||||
|
|
||||||
static void ActionResetView(void)
|
static void ActionResetView(void)
|
||||||
{
|
{
|
||||||
@@ -179,6 +183,8 @@ static const KeyBinding KEYMAP[] = {
|
|||||||
{ KEY_END, KEYGATE_MODAL | KEYGATE_LOADED,ActionZoomToStart, "End", "zoom to start" },
|
{ KEY_END, KEYGATE_MODAL | KEYGATE_LOADED,ActionZoomToStart, "End", "zoom to start" },
|
||||||
{ KEY_E, KEYGATE_MODAL | KEYGATE_STFT, ActionExport, "E", "export PNG" },
|
{ KEY_E, KEYGATE_MODAL | KEYGATE_STFT, ActionExport, "E", "export PNG" },
|
||||||
{ KEY_W, KEYGATE_MODAL | KEYGATE_STFT, ActionExportWav, "W", "export selection WAV" },
|
{ KEY_W, KEYGATE_MODAL | KEYGATE_STFT, ActionExportWav, "W", "export selection WAV" },
|
||||||
|
{ KEY_M, KEYGATE_MODAL | KEYGATE_LOADED,ActionToggleMarker, "M", "marker / ruler tool" },
|
||||||
|
{ KEY_S, KEYGATE_MODAL | KEYGATE_STFT, ActionToggleSpectrum, "S", "spectrum slice (PSD)" },
|
||||||
// Order-sensitive: handled inline (see main loop), listed here for the overlay.
|
// Order-sensitive: handled inline (see main loop), listed here for the overlay.
|
||||||
{ KEY_SPACE, KEYGATE_NONE, NULL, "Space", "play / stop selection" },
|
{ KEY_SPACE, KEYGATE_NONE, NULL, "Space", "play / stop selection" },
|
||||||
{ KEY_ESCAPE,KEYGATE_NONE, NULL, "Esc", "clear selection / close dialog" },
|
{ KEY_ESCAPE,KEYGATE_NONE, NULL, "Esc", "clear selection / close dialog" },
|
||||||
@@ -542,6 +548,10 @@ int main(int argc, char* argv[])
|
|||||||
app.showAbout = false;
|
app.showAbout = false;
|
||||||
} else if (app.showFileBrowser) {
|
} else if (app.showFileBrowser) {
|
||||||
app.showFileBrowser = false;
|
app.showFileBrowser = false;
|
||||||
|
} else if (app.markerMode && app.marker.active) {
|
||||||
|
// Clear the marker measurement first when the ruler is active.
|
||||||
|
app.marker.active = false;
|
||||||
|
app.marker.dragging = false;
|
||||||
} else {
|
} else {
|
||||||
// Clear selections instead of exiting
|
// Clear selections instead of exiting
|
||||||
ClearSelection();
|
ClearSelection();
|
||||||
@@ -567,9 +577,10 @@ int main(int argc, char* argv[])
|
|||||||
bool mouseNearDivider = mousePos.y >= (dividerScreenY - 5) && mousePos.y <= (dividerScreenY + 5) &&
|
bool mouseNearDivider = mousePos.y >= (dividerScreenY - 5) && mousePos.y <= (dividerScreenY + 5) &&
|
||||||
mousePos.x >= selBounds.x && mousePos.x <= selBounds.x + selBounds.width;
|
mousePos.x >= selBounds.x && mousePos.x <= selBounds.x + selBounds.width;
|
||||||
|
|
||||||
// Right-click clears selection
|
// Right-click clears the marker measurement (in marker mode) or the selection.
|
||||||
if (IsMouseButtonPressed(MOUSE_BUTTON_RIGHT) && CheckCollisionPointRec(mousePos, selBounds)) {
|
if (IsMouseButtonPressed(MOUSE_BUTTON_RIGHT) && CheckCollisionPointRec(mousePos, selBounds)) {
|
||||||
ClearSelection();
|
if (app.markerMode) { app.marker.active = false; app.marker.dragging = false; }
|
||||||
|
else ClearSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if click is inside existing selection (for dragging)
|
// Check if click is inside existing selection (for dragging)
|
||||||
@@ -614,6 +625,31 @@ int main(int argc, char* argv[])
|
|||||||
} else {
|
} else {
|
||||||
SetMouseCursor(MOUSE_CURSOR_DEFAULT); // Normal arrow
|
SetMouseCursor(MOUSE_CURSOR_DEFAULT); // Normal arrow
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (app.markerMode) {
|
||||||
|
// Marker/ruler mode: LMB press drops point A, dragging moves B,
|
||||||
|
// release finalizes. Alt / middle-drag still pans (handled
|
||||||
|
// above), so don't drop a marker while panning.
|
||||||
|
SetMouseCursor(MOUSE_CURSOR_CROSSHAIR);
|
||||||
|
bool altPan = IsKeyDown(KEY_LEFT_ALT) || IsKeyDown(KEY_RIGHT_ALT) ||
|
||||||
|
IsMouseButtonDown(MOUSE_BUTTON_MIDDLE);
|
||||||
|
if (!altPan) {
|
||||||
|
float vt = (mousePos.x - selBounds.x) / selBounds.width;
|
||||||
|
float vf = 1.0f - (mousePos.y - selBounds.y) / selBounds.height;
|
||||||
|
float tHere = Clamp(app.view.start + vt * (app.view.end - app.view.start), 0.0f, 1.0f);
|
||||||
|
float fHere = Clamp(app.view.freqStart + vf * (app.view.freqEnd - app.view.freqStart), 0.0f, 1.0f);
|
||||||
|
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
|
||||||
|
app.marker.t0 = tHere; app.marker.f0 = fHere;
|
||||||
|
app.marker.t1 = tHere; app.marker.f1 = fHere;
|
||||||
|
app.marker.dragging = true;
|
||||||
|
app.marker.active = true;
|
||||||
|
}
|
||||||
|
if (app.marker.dragging && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) {
|
||||||
|
app.marker.t1 = tHere; app.marker.f1 = fHere;
|
||||||
|
}
|
||||||
|
if (IsMouseButtonReleased(MOUSE_LEFT_BUTTON)) app.marker.dragging = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
|
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
|
||||||
if (clickInsideSelection) {
|
if (clickInsideSelection) {
|
||||||
// Start dragging existing selection
|
// Start dragging existing selection
|
||||||
@@ -695,6 +731,7 @@ int main(int argc, char* argv[])
|
|||||||
app.sel.isFreqSelecting = false;
|
app.sel.isFreqSelecting = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle divider drag. Works whether or not the scope is currently shown
|
// Handle divider drag. Works whether or not the scope is currently shown
|
||||||
@@ -964,9 +1001,11 @@ int main(int argc, char* argv[])
|
|||||||
if (app.showGrid) DrawSpectrogramGrid(viewBounds, 10, 8, Fade(GRAY, 0.3f));
|
if (app.showGrid) DrawSpectrogramGrid(viewBounds, 10, 8, Fade(GRAY, 0.3f));
|
||||||
DrawSelection(viewBounds);
|
DrawSelection(viewBounds);
|
||||||
DrawSelectionDrag(viewBounds);
|
DrawSelectionDrag(viewBounds);
|
||||||
|
DrawMarkers(viewBounds);
|
||||||
DrawPlayhead(viewBounds);
|
DrawPlayhead(viewBounds);
|
||||||
DrawLabels(viewBounds);
|
DrawLabels(viewBounds);
|
||||||
if (!UiModalOpen()) DrawCursorReadout(viewBounds);
|
if (!UiModalOpen()) DrawCursorReadout(viewBounds);
|
||||||
|
DrawSpectrumPanel(viewBounds);
|
||||||
float maxFreq = (float)app.signal.sampleRate / 2.0f;
|
float maxFreq = (float)app.signal.sampleRate / 2.0f;
|
||||||
float freqMin = app.view.freqStart * maxFreq;
|
float freqMin = app.view.freqStart * maxFreq;
|
||||||
float freqMax = app.view.freqEnd * maxFreq;
|
float freqMax = app.view.freqEnd * maxFreq;
|
||||||
|
|||||||
@@ -106,6 +106,17 @@ typedef struct {
|
|||||||
float dragFreqStart; // selection freq start when the move began
|
float dragFreqStart; // selection freq start when the move began
|
||||||
} Selection;
|
} 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
|
// The visible window into the spectrogram (time + frequency), all 0-1
|
||||||
// normalized, plus the range captured at the start of a pan drag.
|
// normalized, plus the range captured at the start of a pan drag.
|
||||||
typedef struct {
|
typedef struct {
|
||||||
@@ -132,6 +143,12 @@ typedef struct {
|
|||||||
// Time + frequency box selection and its drag/move interaction state.
|
// Time + frequency box selection and its drag/move interaction state.
|
||||||
Selection sel;
|
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
|
// Export settings
|
||||||
float exportScale;
|
float exportScale;
|
||||||
char exportDir[4096];
|
char exportDir[4096];
|
||||||
|
|||||||
+40
@@ -366,6 +366,46 @@ static int CompareDouble(const void* a, const void* b)
|
|||||||
return (da > db) - (da < db);
|
return (da > db) - (da < db);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int ComputePowerSpectrum(const StftResult* stft, float t0, float t1,
|
||||||
|
float* power, int maxBins)
|
||||||
|
{
|
||||||
|
if (!stft || stft->numSegments <= 0 || !power || maxBins <= 0) return 0;
|
||||||
|
|
||||||
|
if (t1 < t0) { float tmp = t0; t0 = t1; t1 = tmp; }
|
||||||
|
t0 = fmaxf(0.0f, t0); t1 = fminf(1.0f, t1);
|
||||||
|
|
||||||
|
int segStart = (int)(t0 * stft->numSegments);
|
||||||
|
int segEnd = (int)(t1 * stft->numSegments);
|
||||||
|
if (segStart < 0) segStart = 0;
|
||||||
|
if (segEnd > stft->numSegments) segEnd = stft->numSegments;
|
||||||
|
if (segEnd <= segStart) segEnd = (segStart < stft->numSegments) ? segStart + 1 : segStart;
|
||||||
|
|
||||||
|
// Bin count from the first computed segment in range.
|
||||||
|
int nbins = 0;
|
||||||
|
for (int s = segStart; s < segEnd; s++) {
|
||||||
|
if (stft->segments[s].spectrum && stft->segments[s].numBins > 0) {
|
||||||
|
nbins = stft->segments[s].numBins; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (nbins < 2) return 0;
|
||||||
|
if (nbins > maxBins) nbins = maxBins;
|
||||||
|
|
||||||
|
for (int b = 0; b < nbins; b++) power[b] = 0.0f;
|
||||||
|
int counted = 0;
|
||||||
|
for (int s = segStart; s < segEnd; s++) {
|
||||||
|
const StftSegment* seg = &stft->segments[s];
|
||||||
|
if (!seg->spectrum || seg->numBins < nbins) continue;
|
||||||
|
for (int b = 0; b < nbins; b++) {
|
||||||
|
float a = seg->spectrum[b].amplitude;
|
||||||
|
power[b] += a * a;
|
||||||
|
}
|
||||||
|
counted++;
|
||||||
|
}
|
||||||
|
if (counted == 0) return 0;
|
||||||
|
for (int b = 0; b < nbins; b++) power[b] /= counted;
|
||||||
|
return nbins;
|
||||||
|
}
|
||||||
|
|
||||||
SpectralStats ComputeSpectralStats(const StftResult* stft,
|
SpectralStats ComputeSpectralStats(const StftResult* stft,
|
||||||
float t0, float t1, float f0, float f1)
|
float t0, float t1, float f0, float f1)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ typedef struct {
|
|||||||
SpectralStats ComputeSpectralStats(const StftResult* stft,
|
SpectralStats ComputeSpectralStats(const StftResult* stft,
|
||||||
float t0, float t1, float f0, float f1);
|
float t0, float t1, float f0, float f1);
|
||||||
|
|
||||||
|
// Averaged power spectrum (the "spectrum slice"/PSD curve). Fills the
|
||||||
|
// caller-provided power[] (length maxBins) with the mean linear power per bin
|
||||||
|
// (amplitude^2 averaged over the segments in time span [t0,t1], 0-1
|
||||||
|
// normalized). Returns the number of bins written, 0 on failure. Convert to dB
|
||||||
|
// at draw time with 10*log10(power).
|
||||||
|
int ComputePowerSpectrum(const StftResult* stft, float t0, float t1,
|
||||||
|
float* power, int maxBins);
|
||||||
|
|
||||||
// --- FFT-size handling & cache ---
|
// --- FFT-size handling & cache ---
|
||||||
void ChangeFFTSize(int newFFT);
|
void ChangeFFTSize(int newFFT);
|
||||||
void SaveToCache(void);
|
void SaveToCache(void);
|
||||||
|
|||||||
@@ -426,6 +426,23 @@ void DrawSidebar(void)
|
|||||||
DrawPanelBox(gridCheck, app.showGrid ? BLUE : DARKGRAY, WHITE);
|
DrawPanelBox(gridCheck, app.showGrid ? BLUE : DARKGRAY, WHITE);
|
||||||
DrawTextScaled("Show Grid", x + 25 * scale, y + 2 * scale, 14, LIGHTGRAY); y += 28 * scale;
|
DrawTextScaled("Show Grid", x + 25 * scale, y + 2 * scale, 14, LIGHTGRAY); y += 28 * 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;
|
||||||
|
DrawPanelBox(markerBtn, app.markerMode ? (Color){ 110, 70, 30, 255 } : (Color){ 50, 50, 60, 255 },
|
||||||
|
app.markerMode ? ORANGE : GRAY);
|
||||||
|
DrawTextScaled(app.markerMode ? "Marker Tool: ON (M)" : "Marker Tool (M)",
|
||||||
|
markerBtn.x + 10 * scale, markerBtn.y + 5 * scale, 13, WHITE);
|
||||||
|
y += 28 * scale;
|
||||||
|
|
||||||
|
Rectangle specBtn = { x, y, sidebarWidth - 10 * scale, 24 * scale };
|
||||||
|
if (Clicked(specBtn)) app.showSpectrum = !app.showSpectrum;
|
||||||
|
DrawPanelBox(specBtn, app.showSpectrum ? (Color){ 30, 70, 90, 255 } : (Color){ 50, 50, 60, 255 },
|
||||||
|
app.showSpectrum ? SKYBLUE : GRAY);
|
||||||
|
DrawTextScaled(app.showSpectrum ? "Spectrum: ON (S)" : "Spectrum Slice (S)",
|
||||||
|
specBtn.x + 10 * scale, specBtn.y + 5 * scale, 13, WHITE);
|
||||||
|
y += 30 * scale;
|
||||||
|
|
||||||
// File loading
|
// File loading
|
||||||
DrawTextScaled("File:", x, y, 14, LIGHTGRAY); y += 20 * scale;
|
DrawTextScaled("File:", x, y, 14, LIGHTGRAY); y += 20 * scale;
|
||||||
Rectangle fileButton = { x, y, sidebarWidth - 10 * scale, 25 * scale };
|
Rectangle fileButton = { x, y, sidebarWidth - 10 * scale, 25 * scale };
|
||||||
|
|||||||
Reference in New Issue
Block a user