diff --git a/src/render.c b/src/render.c index 245bf75..53604f3 100644 --- a/src/render.c +++ b/src/render.c @@ -390,14 +390,22 @@ static void DrawStatPanel(Rectangle bounds, Rectangle sel) float selLeft = sel.x, selRight = sel.x + sel.width; float selCenterY = sel.y + sel.height * 0.5f; - // Prefer the right of the selection; fall back to the left, then clamp. - float boxX = selRight + 10; - if (boxX + boxW > bounds.x + bounds.width) { + // Normally prefer the right of the selection, falling back to the left. But + // the spectrum panel lives in the top-right, so when it's up bias the other + // way to avoid stacking the two boxes on top of each other. + float boxX; + if (app.showSpectrum) { boxX = selLeft - boxW - 10; - if (boxX < bounds.x) boxX = bounds.x; - } - if (boxX + boxW > bounds.x + bounds.width) { - boxX = (selLeft > boxW + 20) ? selLeft - boxW - 10 : bounds.x; + if (boxX < bounds.x) { + boxX = selRight + 10; + if (boxX + boxW > bounds.x + bounds.width) boxX = 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; 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.sel.isTimeSelecting || app.sel.isFreqSelecting || app.sel.isDragging || - app.view.isPanning) return; + app.view.isPanning || app.marker.dragging) return; Vector2 m = GetMousePosition(); 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 }); } +// ============================================================================ +// 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 // ============================================================================ diff --git a/src/render.h b/src/render.h index 5874175..ed9c820 100644 --- a/src/render.h +++ b/src/render.h @@ -26,6 +26,8 @@ void DrawLabels(Rectangle bounds); void DrawSelection(Rectangle bounds); void DrawSelectionDrag(Rectangle bounds); void DrawCursorReadout(Rectangle bounds); +void DrawMarkers(Rectangle bounds); +void DrawSpectrumPanel(Rectangle bounds); void DrawPlayhead(Rectangle bounds); #endif // RENDER_H diff --git a/src/spectrogram.c b/src/spectrogram.c index 71fa197..93bea5e 100644 --- a/src/spectrogram.c +++ b/src/spectrogram.c @@ -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; diff --git a/src/spectrogram_types.h b/src/spectrogram_types.h index f4c888b..836300d 100644 --- a/src/spectrogram_types.h +++ b/src/spectrogram_types.h @@ -106,6 +106,17 @@ typedef struct { 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 { @@ -132,6 +143,12 @@ typedef struct { // 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]; diff --git a/src/stft.c b/src/stft.c index 09cf3f8..af99910 100644 --- a/src/stft.c +++ b/src/stft.c @@ -366,6 +366,46 @@ static int CompareDouble(const void* a, const void* b) 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, float t0, float t1, float f0, float f1) { diff --git a/src/stft.h b/src/stft.h index 659c5c2..d047202 100644 --- a/src/stft.h +++ b/src/stft.h @@ -31,6 +31,14 @@ typedef struct { SpectralStats ComputeSpectralStats(const StftResult* stft, 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 --- void ChangeFFTSize(int newFFT); void SaveToCache(void); diff --git a/src/ui.c b/src/ui.c index 3e01f32..575634e 100644 --- a/src/ui.c +++ b/src/ui.c @@ -426,6 +426,23 @@ void DrawSidebar(void) DrawPanelBox(gridCheck, app.showGrid ? BLUE : DARKGRAY, WHITE); 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 DrawTextScaled("File:", x, y, 14, LIGHTGRAY); y += 20 * scale; Rectangle fileButton = { x, y, sidebarWidth - 10 * scale, 25 * scale };