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:
+41
-2
@@ -128,6 +128,8 @@ void ResetForNewSignal(void)
|
||||
app.sel.isDragging = false;
|
||||
app.sel.isTimeSelecting = false;
|
||||
app.sel.isFreqSelecting = false;
|
||||
app.marker.active = false;
|
||||
app.marker.dragging = false;
|
||||
app.isDividing = false;
|
||||
|
||||
// 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 ActionExport(void) { ExportPNG(&app, 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)
|
||||
{
|
||||
@@ -179,6 +183,8 @@ static const KeyBinding KEYMAP[] = {
|
||||
{ KEY_END, KEYGATE_MODAL | KEYGATE_LOADED,ActionZoomToStart, "End", "zoom to start" },
|
||||
{ KEY_E, KEYGATE_MODAL | KEYGATE_STFT, ActionExport, "E", "export PNG" },
|
||||
{ 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.
|
||||
{ KEY_SPACE, KEYGATE_NONE, NULL, "Space", "play / stop selection" },
|
||||
{ KEY_ESCAPE,KEYGATE_NONE, NULL, "Esc", "clear selection / close dialog" },
|
||||
@@ -542,6 +548,10 @@ int main(int argc, char* argv[])
|
||||
app.showAbout = false;
|
||||
} else if (app.showFileBrowser) {
|
||||
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 {
|
||||
// Clear selections instead of exiting
|
||||
ClearSelection();
|
||||
@@ -567,9 +577,10 @@ int main(int argc, char* argv[])
|
||||
bool mouseNearDivider = mousePos.y >= (dividerScreenY - 5) && mousePos.y <= (dividerScreenY + 5) &&
|
||||
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)) {
|
||||
ClearSelection();
|
||||
if (app.markerMode) { app.marker.active = false; app.marker.dragging = false; }
|
||||
else ClearSelection();
|
||||
}
|
||||
|
||||
// Check if click is inside existing selection (for dragging)
|
||||
@@ -614,6 +625,31 @@ int main(int argc, char* argv[])
|
||||
} else {
|
||||
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 (clickInsideSelection) {
|
||||
// Start dragging existing selection
|
||||
@@ -695,6 +731,7 @@ int main(int argc, char* argv[])
|
||||
app.sel.isFreqSelecting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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));
|
||||
DrawSelection(viewBounds);
|
||||
DrawSelectionDrag(viewBounds);
|
||||
DrawMarkers(viewBounds);
|
||||
DrawPlayhead(viewBounds);
|
||||
DrawLabels(viewBounds);
|
||||
if (!UiModalOpen()) DrawCursorReadout(viewBounds);
|
||||
DrawSpectrumPanel(viewBounds);
|
||||
float maxFreq = (float)app.signal.sampleRate / 2.0f;
|
||||
float freqMin = app.view.freqStart * maxFreq;
|
||||
float freqMax = app.view.freqEnd * maxFreq;
|
||||
|
||||
Reference in New Issue
Block a user