From 0e72bf4172e593db226d37613f97269978206e57 Mon Sep 17 00:00:00 2001 From: Tyler Date: Sun, 24 May 2026 23:35:17 -0700 Subject: [PATCH] feat: add waveform scope view with resizable divider - Create primitives.h/c for waveform visualization (time/amplitude display) - Scope view draws signal as per-pixel min/max envelope (Audacity-style) - Add draggable divider between spectrogram and scope (30%-80% range) - Fix selection bounds to use correct spectrogram area (not full viewport) - Scope syncs with spectrogram zoom/pan for aligned time axis --- rspektrum.make | 6 ++ src/primitives.c | 134 +++++++++++++++++++++++++++++++++++++++++++ src/primitives.h | 49 ++++++++++++++++ src/spectrogram.c | 141 ++++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 324 insertions(+), 6 deletions(-) create mode 100644 src/primitives.c create mode 100644 src/primitives.h diff --git a/rspektrum.make b/rspektrum.make index fcb333a..85e5dc8 100644 --- a/rspektrum.make +++ b/rspektrum.make @@ -121,9 +121,11 @@ OBJECTS := GENERATED += $(OBJDIR)/spectrogram.o GENERATED += $(OBJDIR)/platform_linux.o GENERATED += $(OBJDIR)/utils.o +GENERATED += $(OBJDIR)/primitives.o OBJECTS += $(OBJDIR)/spectrogram.o OBJECTS += $(OBJDIR)/platform_linux.o OBJECTS += $(OBJDIR)/utils.o +OBJECTS += $(OBJDIR)/primitives.o # Rules # ############################################# @@ -199,6 +201,10 @@ $(OBJDIR)/utils.o: src/utils.c @echo "$(notdir $<)" $(SILENT) $(CC) $(ALL_CFLAGS) $(FORCE_INCLUDE) -o "$@" -MF "$(@:%.o=%.d)" -c "$<" +$(OBJDIR)/primitives.o: src/primitives.c + @echo "$(notdir $<)" + $(SILENT) $(CC) $(ALL_CFLAGS) $(FORCE_INCLUDE) -o "$@" -MF "$(@:%.o=%.d)" -c "$<" + -include $(OBJECTS:%.o=%.d) ifneq (,$(PCH)) -include $(PCH_PLACEHOLDER).d diff --git a/src/primitives.c b/src/primitives.c new file mode 100644 index 0000000..86ae68a --- /dev/null +++ b/src/primitives.c @@ -0,0 +1,134 @@ +#include "primitives.h" +#include "raylib.h" +#include +#include +#include + +void InitScopeView(ScopeView* view, WaveformData data, int x, int y, int width, int height) +{ + view->data = data; + view->x = x; + view->y = y; + view->width = width; + view->height = height; + view->viewStart = 0.0f; + view->viewEnd = 1.0f; + view->ampMin = -1.0f; + view->ampMax = 1.0f; + view->cursorT = -1.0f; // -1 means no cursor + view->showGrid = true; + view->gridAlpha = 0.3f; + view->bgColorR = 20; + view->bgColorG = 20; + view->bgColorB = 25; + view->bgColorA = 255; + view->gridR = 80; + view->gridG = 80; + view->gridB = 100; + view->gridA = 180; +} + +static int AmplitudeToY(ScopeView* view, float amp) +{ + return view->y + view->height - (int)((amp - view->ampMin) / (view->ampMax - view->ampMin) * view->height); +} + +static int TimeToX(ScopeView* view, float t) +{ + return view->x + (int)(t * view->width); +} + +void DrawScopeView(ScopeView* view, float cursorT) +{ + if (!view || !view->data.samples) return; + + int totalSamples = view->data.numSamples; + if (totalSamples <= 0) return; + + // Draw background + DrawRectangle(view->x, view->y, view->width, view->height, + (Color){ view->bgColorR, view->bgColorG, view->bgColorB, view->bgColorA }); + + DrawRectangleLines(view->x, view->y, view->width, view->height, + Fade((Color){ view->gridR, view->gridG, view->gridB, view->gridA }, 0.5f)); + + // Grid lines + if (view->showGrid) { + Color gridColor = (Color){ view->gridR, view->gridG, view->gridB, (int)(view->gridAlpha * 255) }; + + // Vertical time divisions + for (int i = 0; i <= 10; i++) { + int x = TimeToX(view, (float)i / 10.0f); + DrawLineV((Vector2){ x, view->y }, (Vector2){ x, view->y + view->height }, gridColor); + } + + // Horizontal amplitude divisions + for (int i = 0; i <= 10; i++) { + float amp = view->ampMin + (float)i / 10.0f * (view->ampMax - view->ampMin); + int y = AmplitudeToY(view, amp); + DrawLine(view->x, y, view->x + view->width, y, gridColor); + } + + // Zero line + int zeroY = AmplitudeToY(view, 0.0f); + DrawLine(view->x, zeroY, view->x + view->width, zeroY, + Fade((Color){ 255, 255, 255, 150 }, 0.7f)); + + // Amplitude labels + char label[32]; + for (int i = 0; i <= 10; i++) { + float amp = view->ampMin + (float)i / 10.0f * (view->ampMax - view->ampMin); + int y = AmplitudeToY(view, amp); + sprintf(label, "%.1f", amp); + DrawText(label, view->x - 35, y - 5, 8, + Fade((Color){ view->gridR, view->gridG, view->gridB, view->gridA }, 0.7f)); + } + } + + // Visible sample range + int startSample = (int)(view->viewStart * totalSamples); + int endSample = (int)(view->viewEnd * totalSamples); + if (startSample < 0) startSample = 0; + if (endSample > totalSamples) endSample = totalSamples; + + int visibleSamples = endSample - startSample; + if (visibleSamples <= 0) return; + + // Compute samples-per-pixel so every pixel gets a column + int spp = (visibleSamples + view->width - 1) / view->width; + if (spp < 1) spp = 1; + + // Draw envelope: per-pixel min/max (Audacity-style) + Color waveColor = (Color){ 200, 220, 255, 255 }; + for (int px = 0; px < view->width; px++) { + int s0 = startSample + px * spp; + int s1 = startSample + (px + 1) * spp; + if (s0 >= endSample) s0 = endSample - 1; + if (s1 > endSample) s1 = endSample; + + float minAmp = view->data.samples[s0]; + float maxAmp = view->data.samples[s0]; + for (int s = s0 + 1; s < s1; s++) { + float v = view->data.samples[s]; + if (v < minAmp) minAmp = v; + if (v > maxAmp) maxAmp = v; + } + + int yTop = AmplitudeToY(view, maxAmp); + int yBot = AmplitudeToY(view, minAmp); + DrawLine(px + view->x, yTop, px + view->x, yBot, waveColor); + } + + // Cursor + if (cursorT >= 0.0f && cursorT <= 1.0f) { + int cursorX = TimeToX(view, cursorT); + if (cursorX >= view->x && cursorX <= view->x + view->width) { + Color cc = (Color){ 255, 80, 80, 255 }; + DrawLine(cursorX, view->y, cursorX, view->y + view->height, cc); + Vector2 p1 = (Vector2){ cursorX, view->y }; + Vector2 p2 = (Vector2){ cursorX - 6, view->y + 10 }; + Vector2 p3 = (Vector2){ cursorX + 6, view->y + 10 }; + DrawTriangle(p1, p2, p3, cc); + } + } +} diff --git a/src/primitives.h b/src/primitives.h new file mode 100644 index 0000000..f9a899b --- /dev/null +++ b/src/primitives.h @@ -0,0 +1,49 @@ +#ifndef PRIMITIVES_H +#define PRIMITIVES_H + +#include + +// Waveform data structure (mirrors AudioSignal for scope rendering) +typedef struct { + float* samples; + int numSamples; + int sampleRate; +} WaveformData; + +// Scope view state for time/amplitude waveform display +typedef struct { + WaveformData data; + + // View bounds (in pixels) + int x, y; + int width, height; + + // Time view (0-1 normalized within the signal) + float viewStart; // start of visible time + float viewEnd; // end of visible time + + // Amplitude view + float ampMin; // minimum visible amplitude (-1.0 to 1.0) + float ampMax; // maximum visible amplitude + + // Cursor/selection + float cursorT; // 0-1 normalized time position of cursor + + // Display settings + bool showGrid; + float gridAlpha; // 0-1 alpha for grid lines + + // Background color + int bgColorR, bgColorG, bgColorB, bgColorA; + + // Grid color + int gridR, gridG, gridB, gridA; +} ScopeView; + +// Initialize a scope view with bounds and waveform +void InitScopeView(ScopeView* view, WaveformData data, int x, int y, int width, int height); + +// Draw the scope view with optional cursor/playhead +void DrawScopeView(ScopeView* view, float cursorT); + +#endif // PRIMITIVES_H diff --git a/src/spectrogram.c b/src/spectrogram.c index 86d44d0..6562d04 100644 --- a/src/spectrogram.c +++ b/src/spectrogram.c @@ -5,6 +5,7 @@ #include "resource_dir.h" #include "platform.h" #include "utils.h" +#include "primitives.h" #include #include @@ -172,6 +173,16 @@ typedef struct { // the current view range. int skipFactor; bool highResFinished; + + // 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 } SpectrogramApp; // ============================================================================ @@ -1733,7 +1744,17 @@ int main(int argc, char* argv[]) app.highResFinished = false; app.isPlaying = false; app.playbackFinished = false; - + app.showScope = true; + app.dividerY = 0.6f; // Start with 60% spectro, 40% scope + app.isDividing = false; + app.dividerStartPos = (Vector2){ 0, 0 }; + app.dividerStartY = 0; + + // Initialize scope view (data synced in render loop when signal loads) + InitScopeView(&app.scopeView, + (WaveformData){app.signal.samples, app.signal.numSamples, app.signal.sampleRate}, + 0, 0, GetScreenWidth(), 200); + GenerateColormapTexture(); ScanDirectory(GetWorkingDirectory()); @@ -1802,6 +1823,11 @@ int main(int argc, char* argv[]) ScanDirectory(GetWorkingDirectory()); } + // Scope view toggle (P key) + if (IsKeyPressed(KEY_P)) { + app.showScope = !app.showScope; + } + // File browser update if (app.showFileBrowser) { // Browser handles its own input @@ -1839,11 +1865,12 @@ int main(int argc, char* argv[]) float topMargin = 50 * viewScale; float bottomMargin = 10 * viewScale; + float spectroHeight = (GetScreenHeight() - topMargin - bottomMargin - labelHeight - scrollbarHeight - 10 * viewScale) * app.dividerY; Rectangle viewBounds = { sidebarWidth + freqLabelWidth, topMargin, GetScreenWidth() - sidebarWidth - freqLabelWidth - vScrollbarWidth - 20 * viewScale, - GetScreenHeight() - topMargin - bottomMargin - labelHeight - scrollbarHeight - 10 * viewScale + spectroHeight }; // Zoom with mouse wheel (zooms both time and frequency to maintain aspect ratio) @@ -2023,14 +2050,20 @@ int main(int argc, char* argv[]) float selVScrollbarWidth = 18 * selScale; float selTopMargin = 50 * selScale; float selBottomMargin = 10 * selScale; - + + float selSpectroHeight = (GetScreenHeight() - selTopMargin - selBottomMargin - selLabelHeight - selScrollbarHeight - 10.0f * selScale) * app.dividerY; Rectangle selBounds = { selSidebarWidth + selFreqLabelWidth, selTopMargin, GetScreenWidth() - selSidebarWidth - selFreqLabelWidth - selVScrollbarWidth - 20.0f * selScale, - GetScreenHeight() - selTopMargin - selBottomMargin - selLabelHeight - selScrollbarHeight - 10.0f * selScale + selSpectroHeight }; Vector2 mousePos = GetMousePosition(); + + // Calculate divider screen position (for hover detection) + float dividerScreenY = selTopMargin + selSpectroHeight; + bool mouseNearDivider = mousePos.y >= (dividerScreenY - 5) && mousePos.y <= (dividerScreenY + 5) && + mousePos.x >= selBounds.x && mousePos.x <= selBounds.x + selBounds.width; // Right-click clears selection if (IsMouseButtonPressed(MOUSE_BUTTON_RIGHT) && CheckCollisionPointRec(mousePos, selBounds)) { @@ -2072,6 +2105,16 @@ int main(int argc, char* argv[]) // LMB drag = box select (time + frequency) OR drag existing selection if (app.loaded && !app.showFileBrowser && CheckCollisionPointRec(mousePos, selBounds)) { + // Set cursor to resize all when near divider + if (mouseNearDivider && !app.isDividing) { + SetMouseCursor(MOUSE_CURSOR_RESIZE_ALL); + } else if (app.isDraggingSelection) { + SetMouseCursor(MOUSE_CURSOR_RESIZE_ALL); // 4-way arrow while dragging + } else if (hoverInsideSelection) { + SetMouseCursor(MOUSE_CURSOR_POINTING_HAND); // Pointing hand on hover + } else { + SetMouseCursor(MOUSE_CURSOR_DEFAULT); // Normal arrow + } if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { if (clickInsideSelection) { // Start dragging existing selection @@ -2157,7 +2200,35 @@ int main(int argc, char* argv[]) } } } - + + // Handle divider drag + if (app.loaded && app.showScope && !app.showFileBrowser) { + // Calculate divider position (using same calculation as selBounds) + float viewScale = GetUIScale(); + float topMargin = 50 * viewScale; + float bottomMargin = 10 * viewScale; + float labelHeight = 15 * viewScale; + float scrollbarHeight = 18 * viewScale; + float totalHeight = GetScreenHeight() - topMargin - bottomMargin - labelHeight - scrollbarHeight; + + // Check if mouse is near divider + if (mouseNearDivider && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { + app.isDividing = true; + app.dividerStartPos = mousePos; + app.dividerStartY = app.dividerY; + } + if (app.isDividing && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) { + float dy = (mousePos.y - app.dividerStartPos.y) / GetScreenHeight(); + app.dividerY = app.dividerStartY + dy; + // Clamp to reasonable range: 0.3 to 0.8 (30% to 80% for spectrogram) + if (app.dividerY < 0.3f) app.dividerY = 0.3f; + if (app.dividerY > 0.8f) app.dividerY = 0.8f; + } + if (IsMouseButtonReleased(MOUSE_LEFT_BUTTON)) { + app.isDividing = false; + } + } + // Processing (incremental across frames) if (app.loaded && !app.stftComputed) { if (app.loadingPhase == 0) { @@ -2258,11 +2329,12 @@ int main(int argc, char* argv[]) float bottomMargin = 10 * renderScale; // Spectrogram area (excludes labels and scrollbars) + float spectroHeight = (GetScreenHeight() - topMargin - bottomMargin - labelHeight - scrollbarHeight - 10 * renderScale) * app.dividerY; Rectangle viewBounds = { sidebarWidth + freqLabelWidth, topMargin, GetScreenWidth() - sidebarWidth - freqLabelWidth - vScrollbarWidth - 20 * renderScale, - GetScreenHeight() - topMargin - bottomMargin - labelHeight - scrollbarHeight - 10 * renderScale + spectroHeight }; // Time labels sit just below the spectrogram Rectangle timeLabelArea = { viewBounds.x, viewBounds.y + viewBounds.height, viewBounds.width, labelHeight }; @@ -2397,6 +2469,63 @@ int main(int argc, char* argv[]) float freqMax = app.freqViewEnd * maxFreq; DrawTextScaled(TextFormat("Freq: %.0f-%.0f Hz", freqMin, freqMax), viewBounds.x, viewBounds.y - 30, 20, LIGHTGRAY); + + // Draw waveform scope view underneath the spectrogram + if (app.showScope && app.loaded && app.signal.samples != NULL) { + float totalArea = GetScreenHeight() - topMargin - bottomMargin - labelHeight - scrollbarHeight - 10 * renderScale; + float scopeHeight = totalArea * (1.0f - app.dividerY) - 30 * renderScale; + app.scopeView.y = viewBounds.y + viewBounds.height + 30; + app.scopeView.x = viewBounds.x; + app.scopeView.width = viewBounds.width; + app.scopeView.height = (int)scopeHeight; + // Keep time view in sync with spectrogram view + app.scopeView.viewStart = app.viewStart; + app.scopeView.viewEnd = app.viewEnd; + // Update waveform data + app.scopeView.data.samples = app.signal.samples; + app.scopeView.data.numSamples = app.signal.numSamples; + app.scopeView.data.sampleRate = app.signal.sampleRate; + // Show playhead if playing + if (app.isPlaying) { + DrawScopeView(&app.scopeView, app.timeSelectionStart + app.playheadT * (app.timeSelectionEnd - app.timeSelectionStart)); + } else { + DrawScopeView(&app.scopeView, -1.0f); + } + // Draw scope label + DrawTextScaled("Waveform (Time/Amplitude)", viewBounds.x, app.scopeView.y - 20, 14, LIGHTGRAY); + } + + // Draw divider line between spectrogram and scope + if (app.showScope && app.loaded) { + float dividerY = viewBounds.y + viewBounds.height; + Color dividerColor = app.isDividing ? CYAN : Fade((Color){ 180, 180, 200, 255 }, 0.7f); + + // Draw divider with handle + int handleW = 40; + int handleH = 10; + int handleX = viewBounds.x + viewBounds.width / 2 - handleW / 2; + int handleY = (int)dividerY - handleH / 2; + + if (app.isDividing) { + // Highlighted handle while dragging + DrawRectangle(handleX, handleY, handleW, handleH, CYAN); + DrawRectangleLines(handleX, handleY, handleW, handleH, WHITE); + } else { + // Normal handle + DrawRectangle(handleX, handleY, handleW, handleH, GRAY); + DrawRectangleLines(handleX, handleY, handleW, handleH, Fade(YELLOW, 0.6f)); + + // Draw handle grips (3 dots) + Color gripColor = Fade(WHITE, 0.6f); + DrawPixel(handleX + handleW / 3, handleY + handleH / 2, gripColor); + DrawPixel(handleX + handleW / 2, handleY + handleH / 2, gripColor); + DrawPixel(handleX + handleW * 2 / 3, handleY + handleH / 2, gripColor); + } + + // Draw line extending from handle to edges + DrawLine(viewBounds.x, (int)dividerY, handleX, (int)dividerY, dividerColor); + DrawLine(handleX + handleW, (int)dividerY, viewBounds.x + viewBounds.width, (int)dividerY, dividerColor); + } } else if (!app.showFileBrowser) { const char* msg1 = "Press 'O' or click 'Open File Browser' to load a WAV"; const char* msg2 = "Or drag & drop a file, or use: ./rspektrum ";