refactor: split spectrogram.c into per-concern modules
spectrogram.c was ~2950 lines holding everything. Break it into cohesive translation units; spectrogram.c keeps only globals + the main frame loop. New modules: - spectrogram_types.h shared types, constants, extern globals, inline math - fft.c/.h FFT, bit-reverse, twiddle (standalone, no app deps) - stft.c/.h STFT compute, adaptive resolution, FFT-size LRU cache - audio.c/.h WAV/ffmpeg load, FreeSignal, bandpass, playback - render.c/.h UI scaling, colormaps, texture gen, on-screen drawing - ui.c/.h file browser, sidebar, sliders, PNG export Also: - utils.c now includes utils.h instead of re-typedef'ing AudioSignal/ SignalStats (they had to be hand-synced before). - Remove dead code: ApplyHannWindow and ComputeSTFTHighResRange were never called (the live high-res path is ComputeNextHighResChunk). - Delete the unused raylib-template main.c. - rspektrum.make: build the new units. premake5.lua: glob src/**.c so a future regen stays correct. Pure code movement otherwise; no behavior change. Builds clean (-Wshadow). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+494
@@ -0,0 +1,494 @@
|
||||
// render.c - colormaps, spectrogram texture generation, and on-screen drawing
|
||||
#include "render.h"
|
||||
|
||||
#include <math.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
|
||||
// ===== UI scaling and scaled text =====
|
||||
// Base resolution for proportional UI scaling.
|
||||
// GetUIScale() uses logical screen (not framebuffer) dimensions so that
|
||||
// layout stays based on window size alone. FLAG_WINDOW_HIGHDPI makes
|
||||
// BeginDrawing() render to the framebuffer at the correct resolution, so
|
||||
// every 1px drawn in layout coordinates automatically maps to the right
|
||||
// physical size on any monitor.
|
||||
float GetUIScale(void)
|
||||
{
|
||||
float scaleX = (float)GetScreenWidth() / BASE_WIDTH;
|
||||
float scaleY = (float)GetScreenHeight() / BASE_HEIGHT;
|
||||
return (scaleX + scaleY) / 2.0f;
|
||||
}
|
||||
|
||||
void DrawTextScaled(const char* text, float x, float y, float baseSize, Color color)
|
||||
{
|
||||
if (mainFont.texture.id == 0) {
|
||||
// Fallback to default if font not loaded
|
||||
DrawText(text, (int)x, (int)y, (int)baseSize, color);
|
||||
return;
|
||||
}
|
||||
float scaledSize = baseSize * GetUIScale();
|
||||
float spacing = scaledSize * 0.25f; // 25% of font size for spacing
|
||||
DrawTextEx(mainFont, text, (Vector2){ x, y }, scaledSize, spacing, color);
|
||||
}
|
||||
|
||||
float MeasureTextScaled(const char* text, float baseSize)
|
||||
{
|
||||
if (mainFont.texture.id == 0) return MeasureText(text, (int)baseSize);
|
||||
float scaledSize = baseSize * GetUIScale();
|
||||
float spacing = scaledSize * 0.25f;
|
||||
return MeasureTextEx(mainFont, text, scaledSize, spacing).x;
|
||||
}
|
||||
|
||||
// ===== Colormaps =====
|
||||
static Color GetColormapColor(float t, ColormapType type)
|
||||
{
|
||||
t = Clamp(t, 0.0f, 1.0f);
|
||||
|
||||
switch (type) {
|
||||
case COLORMAP_GRAYS: {
|
||||
unsigned char v = (unsigned char)(t * 255);
|
||||
return (Color){ v, v, v, 255 };
|
||||
}
|
||||
case COLORMAP_INFERNO: {
|
||||
float r = 0.0f, g = 0.0f, b = 0.0f;
|
||||
if (t < 0.25f) { t = t / 0.25f; r = 0.0f + t * 0.5f; g = 0.0f; b = 0.0f + t * 0.3f; }
|
||||
else if (t < 0.5f) { t = (t - 0.25f) / 0.25f; r = 0.5f + t * 0.5f; g = 0.0f + t * 0.3f; b = 0.3f + t * 0.4f; }
|
||||
else if (t < 0.75f) { t = (t - 0.5f) / 0.25f; r = 1.0f; g = 0.3f + t * 0.5f; b = 0.7f + t * 0.2f; }
|
||||
else { t = (t - 0.75f) / 0.25f; r = 1.0f; g = 0.8f + t * 0.2f; b = 0.9f + t * 0.1f; }
|
||||
return (Color){ (unsigned char)(r * 255), (unsigned char)(g * 255), (unsigned char)(b * 255), 255 };
|
||||
}
|
||||
case COLORMAP_VIRIDIS: {
|
||||
float r, g, b;
|
||||
if (t < 0.25f) { t = t / 0.25f; r = 0.27f + t * 0.13f; g = 0.00f + t * 0.33f; b = 0.33f + t * 0.27f; }
|
||||
else if (t < 0.5f) { t = (t - 0.25f) / 0.25f; r = 0.40f + t * 0.16f; g = 0.33f + t * 0.29f; b = 0.60f - t * 0.20f; }
|
||||
else if (t < 0.75f) { t = (t - 0.5f) / 0.25f; r = 0.56f + t * 0.24f; g = 0.62f + t * 0.23f; b = 0.40f - t * 0.20f; }
|
||||
else { t = (t - 0.75f) / 0.25f; r = 0.80f + t * 0.17f; g = 0.85f + t * 0.12f; b = 0.20f - t * 0.15f; }
|
||||
return (Color){ (unsigned char)(r * 255), (unsigned char)(g * 255), (unsigned char)(b * 255), 255 };
|
||||
}
|
||||
case COLORMAP_PLASMA: {
|
||||
float r = 0.05f + t * 0.9f;
|
||||
float g = 0.0f + t * 0.6f + (t > 0.5f ? (t - 0.5f) * 0.4f : 0.0f);
|
||||
float b = 0.6f - t * 0.5f;
|
||||
return (Color){ (unsigned char)(r * 255), (unsigned char)(g * 255), (unsigned char)(b * 255), 255 };
|
||||
}
|
||||
case COLORMAP_HOT: {
|
||||
float r = Clamp(t * 3.0f, 0.0f, 1.0f);
|
||||
float g = Clamp((t - 0.33f) * 3.0f, 0.0f, 1.0f);
|
||||
float b = Clamp((t - 0.66f) * 3.0f, 0.0f, 1.0f);
|
||||
return (Color){ (unsigned char)(r * 255), (unsigned char)(g * 255), (unsigned char)(b * 255), 255 };
|
||||
}
|
||||
case COLORMAP_COOL: {
|
||||
return (Color){ (unsigned char)(t * 255), (unsigned char)((1.0f - t) * 255), 255, 255 };
|
||||
}
|
||||
default: return GRAY;
|
||||
}
|
||||
}
|
||||
|
||||
void GenerateColormapTexture(void)
|
||||
{
|
||||
if (colormapTexture.id != 0) UnloadTexture(colormapTexture);
|
||||
Image img = GenImageColor(256, 1, WHITE);
|
||||
Color* pixels = (Color*)img.data;
|
||||
for (int i = 0; i < 256; i++) pixels[i] = GetColormapColor(i / 255.0f, app.colormap);
|
||||
colormapTexture = LoadTextureFromImage(img);
|
||||
UnloadImage(img);
|
||||
}
|
||||
|
||||
// ===== Spectrogram texture =====
|
||||
void GenerateSpectrogramTexture(StftResult* stft, Image* image, Texture2D* texture)
|
||||
{
|
||||
if (stft->numSegments == 0) return;
|
||||
int width = stft->numSegments;
|
||||
int height = stft->segments[0].numBins;
|
||||
int fftSize = (height - 1) * 2;
|
||||
float freqPerBin = (float)stft->sampleRate / fftSize;
|
||||
|
||||
UnloadImage(*image); // release previous image (NULL-safe on first call)
|
||||
*image = GenImageColor(width, height, BLACK);
|
||||
Color* pixels = (Color*)image->data;
|
||||
|
||||
// Find max amplitude for normalization (skip NULL segments)
|
||||
float maxAmplitude = 0.0001f;
|
||||
for (int seg = 0; seg < stft->numSegments; seg++) {
|
||||
if (stft->segments[seg].spectrum == NULL) continue;
|
||||
for (int bin = 0; bin < stft->segments[seg].numBins; bin++)
|
||||
if (stft->segments[seg].spectrum[bin].amplitude > maxAmplitude)
|
||||
maxAmplitude = stft->segments[seg].spectrum[bin].amplitude;
|
||||
}
|
||||
|
||||
// ===== SYNCHROSQUEEZING =====
|
||||
// Reassign energy to true frequencies using derivative STFT
|
||||
|
||||
// Accumulation buffer for reassigned energy
|
||||
float* accumBuffer = (float*)calloc(width * height, sizeof(float));
|
||||
|
||||
// Noise threshold: only reassign bins with significant energy
|
||||
float noiseThreshold = maxAmplitude * 0.01f; // 1% of max amplitude
|
||||
|
||||
for (int seg = 0; seg < width; seg++) {
|
||||
// Skip segments that haven't been computed yet (overview/high-res transition)
|
||||
if (stft->segments[seg].spectrum == NULL) continue;
|
||||
|
||||
for (int bin = 0; bin < height; bin++) {
|
||||
FrequencyData* V_f = &stft->segments[seg].spectrum[bin];
|
||||
FrequencyData* V_fd = &stft->segments[seg].derivativeSpectrum[bin];
|
||||
|
||||
float amplitude = V_f->amplitude;
|
||||
|
||||
// Skip noise bins
|
||||
if (amplitude < noiseThreshold) continue;
|
||||
|
||||
// Compute instantaneous frequency using synchrosqueezing formula:
|
||||
// ω̂ = bin_freq + Re[V_fd / (i * V_f)]
|
||||
// Complex division: (a+bi)/(c+di) = ((ac+bd) + (bc-ad)i) / (c²+d²)
|
||||
// We need Re[(a+bi) / (i*(c+di))] = Re[(a+bi) / (-d+ci)] = (ad+bc)/(c²+d²)
|
||||
|
||||
float V_f_real = amplitude * cosf(V_f->phase);
|
||||
float V_f_imag = amplitude * sinf(V_f->phase);
|
||||
float V_fd_real = V_fd->amplitude * cosf(V_fd->phase);
|
||||
float V_fd_imag = V_fd->amplitude * sinf(V_fd->phase);
|
||||
|
||||
float denom = V_f_real * V_f_real + V_f_imag * V_f_imag;
|
||||
|
||||
float trueFreq = V_f->frequency; // Default to bin frequency
|
||||
|
||||
if (denom > 1e-10f) {
|
||||
// Re[V_fd / (i * V_f)] = (-V_fd_real * V_f_imag + V_fd_imag * V_f_real) / denom
|
||||
// Note the MINUS sign on the first term
|
||||
float correction = (-V_fd_real * V_f_imag + V_fd_imag * V_f_real) / denom;
|
||||
trueFreq = V_f->frequency + correction;
|
||||
}
|
||||
|
||||
// Clamp to valid range
|
||||
if (trueFreq < 0) trueFreq = 0;
|
||||
if (trueFreq >= stft->sampleRate / 2.0f) trueFreq = stft->sampleRate / 2.0f - 1;
|
||||
|
||||
// Map to bin coordinate
|
||||
float targetBinF = trueFreq / freqPerBin;
|
||||
if (targetBinF < 0) targetBinF = 0;
|
||||
if (targetBinF >= height) targetBinF = height - 0.001f;
|
||||
|
||||
// Bilinear splatting to neighboring bins
|
||||
int bin0 = (int)targetBinF;
|
||||
int bin1 = bin0 + 1;
|
||||
if (bin1 >= height) bin1 = height - 1;
|
||||
|
||||
float frac = targetBinF - bin0;
|
||||
int idx0 = (height - 1 - bin0) * width + seg;
|
||||
int idx1 = (height - 1 - bin1) * width + seg;
|
||||
|
||||
accumBuffer[idx0] += amplitude * (1 - frac);
|
||||
accumBuffer[idx1] += amplitude * frac;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert accumulation buffer to colors
|
||||
for (int i = 0; i < width * height; i++) {
|
||||
if (accumBuffer[i] > 0.0001f) {
|
||||
float db = AmplitudeToDecibels(accumBuffer[i]);
|
||||
float normalized = (db - app.amplitudeFloorDb) / (app.amplitudeCeilingDb - app.amplitudeFloorDb);
|
||||
normalized = Clamp(normalized, 0.0f, 1.0f);
|
||||
pixels[i] = GetColormapColor(normalized, app.colormap);
|
||||
}
|
||||
}
|
||||
|
||||
free(accumBuffer);
|
||||
|
||||
if (texture->id != 0) UnloadTexture(*texture);
|
||||
*texture = LoadTextureFromImage(*image);
|
||||
SetTextureFilter(*texture, TEXTURE_FILTER_BILINEAR);
|
||||
}
|
||||
|
||||
// Compute auto-adjusted amplitude floor/ceiling from STFT data
|
||||
|
||||
// ===== Grid, labels, selection, playhead =====
|
||||
void DrawSpectrogramGrid(Rectangle bounds, int numCellsX, int numCellsY, Color color)
|
||||
{
|
||||
float cellWidth = bounds.width / numCellsX, cellHeight = bounds.height / numCellsY;
|
||||
for (int i = 0; i <= numCellsX; i++) {
|
||||
float x = bounds.x + i * cellWidth;
|
||||
DrawLineV((Vector2){ x, bounds.y }, (Vector2){ x, bounds.y + bounds.height }, color);
|
||||
}
|
||||
for (int i = 0; i <= numCellsY; i++) {
|
||||
float y = bounds.y + bounds.height - i * cellHeight;
|
||||
DrawLineV((Vector2){ bounds.x, y }, (Vector2){ bounds.x + bounds.width, y }, color);
|
||||
}
|
||||
}
|
||||
|
||||
void DrawLabels(Rectangle bounds)
|
||||
{
|
||||
int baseFontSize = 12;
|
||||
Color textColor = LIGHTGRAY;
|
||||
|
||||
// Time labels
|
||||
for (int i = 0; i <= 10; i++) {
|
||||
float t = (float)i / 10;
|
||||
float timeSec = (app.viewStart + t * (app.viewEnd - app.viewStart)) * app.signal.duration;
|
||||
float x = bounds.x + t * bounds.width;
|
||||
char label[32];
|
||||
if (timeSec >= 60) sprintf(label, "%d:%02d", (int)(timeSec / 60), (int)(timeSec) % 60);
|
||||
else sprintf(label, "%.1fs", timeSec);
|
||||
DrawTextScaled(label, x, bounds.y + bounds.height + 5, baseFontSize, textColor);
|
||||
}
|
||||
|
||||
// Frequency labels adapted to current zoom level
|
||||
float maxFreq = (float)app.signal.sampleRate / 2.0f;
|
||||
float freqMin = app.freqViewStart * maxFreq;
|
||||
float freqMax = app.freqViewEnd * maxFreq;
|
||||
|
||||
// Choose tick spacing based on zoom range
|
||||
float freqRange = freqMax - freqMin;
|
||||
int tickSpacing;
|
||||
if (freqRange < 20) tickSpacing = 5;
|
||||
else if (freqRange < 50) tickSpacing = 10;
|
||||
else if (freqRange < 200) tickSpacing = 50;
|
||||
else if (freqRange < 1000) tickSpacing = 100;
|
||||
else if (freqRange < 5000) tickSpacing = 200;
|
||||
else if (freqRange < 20000) tickSpacing = 1000;
|
||||
else if (freqRange < 50000) tickSpacing = 5000;
|
||||
else tickSpacing = 10000;
|
||||
|
||||
// Labels use next coarser spacing so they stay readable
|
||||
int labelSpacing = tickSpacing;
|
||||
if (labelSpacing <= 10) labelSpacing = 10;
|
||||
else if (labelSpacing <= 50) labelSpacing = 50;
|
||||
else if (labelSpacing <= 200) labelSpacing = 200;
|
||||
else if (labelSpacing <= 1000) labelSpacing = 1000;
|
||||
else if (labelSpacing <= 5000) labelSpacing = 5000;
|
||||
else labelSpacing = 10000;
|
||||
|
||||
// Round freqMin up to nearest tick spacing (smallest multiple >= freqMin)
|
||||
int firstTick = ((int)(freqMin / tickSpacing)) * tickSpacing;
|
||||
if (firstTick < freqMin) firstTick += tickSpacing;
|
||||
for (int hz = firstTick; hz <= freqMax; hz += tickSpacing) {
|
||||
float t = (hz - freqMin) / freqRange;
|
||||
float y = bounds.y + bounds.height - t * bounds.height;
|
||||
Color tickColor = (hz % 1000 == 0) ? GRAY : Fade(GRAY, 0.4f);
|
||||
DrawLineV((Vector2){ bounds.x - 5, y }, (Vector2){ bounds.x, y }, tickColor);
|
||||
}
|
||||
|
||||
// Draw labels at the coarser spacing
|
||||
for (int hz = firstTick; hz <= freqMax; hz += labelSpacing) {
|
||||
float t = (hz - freqMin) / freqRange;
|
||||
float y = bounds.y + bounds.height - t * bounds.height;
|
||||
char label[32];
|
||||
if (hz < 10000) sprintf(label, "%.0fHz", (float)hz);
|
||||
else sprintf(label, "%.0fkHz", (float)hz / 1000.0f);
|
||||
DrawTextScaled(label, bounds.x - 70, y - 5, baseFontSize, textColor);
|
||||
}
|
||||
}
|
||||
|
||||
void DrawSelection(Rectangle bounds)
|
||||
{
|
||||
// Only draw if selection is not full range AND not currently dragging
|
||||
bool hasSelection = (app.timeSelectionStart > 0.001f || app.timeSelectionEnd < 0.999f ||
|
||||
app.freqSelectionStart > 0.001f || app.freqSelectionEnd < 0.999f);
|
||||
if (!hasSelection || app.isTimeSelecting) return; // Don't draw overlay while dragging
|
||||
|
||||
Color overlayColor = Fade(BLACK, 0.25f); // Lighter overlay
|
||||
|
||||
// Convert signal coordinates to viewport coordinates
|
||||
float viewWidth = app.viewEnd - app.viewStart;
|
||||
float freqWidth = app.freqViewEnd - app.freqViewStart;
|
||||
|
||||
float selStartX = bounds.x + ((app.timeSelectionStart - app.viewStart) / viewWidth) * bounds.width;
|
||||
float selEndX = bounds.x + ((app.timeSelectionEnd - app.viewStart) / viewWidth) * bounds.width;
|
||||
float selStartY = bounds.y + bounds.height - ((app.freqSelectionEnd - app.freqViewStart) / freqWidth) * bounds.height;
|
||||
float selEndY = bounds.y + bounds.height - ((app.freqSelectionStart - app.freqViewStart) / freqWidth) * bounds.height;
|
||||
|
||||
// Clamp to viewport bounds
|
||||
selStartX = fmaxf(bounds.x, fminf(bounds.x + bounds.width, selStartX));
|
||||
selEndX = fmaxf(bounds.x, fminf(bounds.x + bounds.width, selEndX));
|
||||
selStartY = fmaxf(bounds.y, fminf(bounds.y + bounds.height, selStartY));
|
||||
selEndY = fmaxf(bounds.y, fminf(bounds.y + bounds.height, selEndY));
|
||||
|
||||
// Draw overlay outside the selection box
|
||||
DrawRectangle(bounds.x, bounds.y, selStartX - bounds.x, bounds.height, overlayColor);
|
||||
DrawRectangle(selEndX, bounds.y, bounds.x + bounds.width - selEndX, bounds.height, overlayColor);
|
||||
DrawRectangle(selStartX, bounds.y, selEndX - selStartX, selStartY - bounds.y, overlayColor);
|
||||
DrawRectangle(selStartX, selEndY, selEndX - selStartX, bounds.y + bounds.height - selEndY, overlayColor);
|
||||
|
||||
// Draw selection box border
|
||||
DrawRectangleLinesEx((Rectangle){ selStartX, selStartY, selEndX - selStartX, selEndY - selStartY }, 2, YELLOW);
|
||||
|
||||
// Display selection stats inside viewport, clamped to fit
|
||||
{
|
||||
int startSample = (int)(app.timeSelectionStart * app.signal.numSamples);
|
||||
int endSample = (int)(app.timeSelectionEnd * app.signal.numSamples);
|
||||
SignalStats stats = ComputeSignalStats(&app.signal, startSample, endSample);
|
||||
|
||||
if (stats.durationSec > 0.0f && app.signal.samples != NULL) {
|
||||
char lines[5][128];
|
||||
int lineCount = 0;
|
||||
int fontSize = 10;
|
||||
int maxTextW = 0;
|
||||
|
||||
sprintf(lines[lineCount++], "Duration: %.3fs", stats.durationSec);
|
||||
sprintf(lines[lineCount++], "Energy: %.2f", stats.energy);
|
||||
sprintf(lines[lineCount++], "Peak: %.3f", stats.peakAmplitude);
|
||||
sprintf(lines[lineCount++], "RMS: %.3f", stats.rmsAmplitude);
|
||||
sprintf(lines[lineCount++], "PAPR: %.1f dB", stats.paprDb);
|
||||
|
||||
// Measure text width
|
||||
for (int i = 0; i < lineCount; i++) {
|
||||
int textW = MeasureText(lines[i], fontSize);
|
||||
if (textW > maxTextW) maxTextW = textW;
|
||||
}
|
||||
|
||||
int boxW = maxTextW + 20;
|
||||
int boxH = lineCount * 14 + 12;
|
||||
|
||||
// Center vertically on the selection box, clamp to viewport
|
||||
float selCenterY = (selStartY + selEndY) / 2.0f;
|
||||
float boxY = selCenterY - boxH / 2.0f;
|
||||
if (boxY < bounds.y) boxY = bounds.y;
|
||||
if (boxY + boxH > bounds.y + bounds.height) boxY = bounds.y + bounds.height - boxH;
|
||||
|
||||
// Place to the right of the selection, or left if not enough room
|
||||
float boxX = selEndX + 10;
|
||||
if (boxX + boxW > bounds.x + bounds.width) {
|
||||
boxX = selStartX - boxW - 10;
|
||||
if (boxX < bounds.x) boxX = bounds.x;
|
||||
}
|
||||
|
||||
// Clamp to viewport bounds
|
||||
if (boxX + boxW > bounds.x + bounds.width) {
|
||||
// Not enough room on right — draw to the left of selection
|
||||
if (selStartX > boxW + 20) {
|
||||
boxX = selStartX - boxW - 10;
|
||||
} else {
|
||||
boxX = bounds.x;
|
||||
}
|
||||
}
|
||||
if (boxY + boxH > bounds.y + bounds.height) {
|
||||
boxY = bounds.y + bounds.height - boxH;
|
||||
}
|
||||
|
||||
// Draw background box
|
||||
DrawRectangle((int)boxX, (int)boxY, boxW, boxH, (Color){ 0, 0, 0, 200 });
|
||||
DrawRectangleLines((int)boxX, (int)boxY, boxW, boxH, Fade(YELLOW, 0.6f));
|
||||
|
||||
// Draw text
|
||||
for (int i = 0; i < lineCount; i++) {
|
||||
DrawText(lines[i], (int)boxX + 10, (int)boxY + 8 + i * 14, fontSize, LIGHTGRAY);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void DrawSelectionDrag(Rectangle bounds)
|
||||
{
|
||||
// Draw bounding box while dragging (no overlay)
|
||||
if ((!app.isTimeSelecting && !app.isFreqSelecting && !app.isDraggingSelection) ||
|
||||
(app.isDraggingSelection && !IsMouseButtonDown(MOUSE_LEFT_BUTTON))) return;
|
||||
|
||||
// Convert signal coordinates to viewport coordinates
|
||||
float viewWidth = app.viewEnd - app.viewStart;
|
||||
float freqWidth = app.freqViewEnd - app.freqViewStart;
|
||||
|
||||
float selStartX = bounds.x + ((app.timeSelectionStart - app.viewStart) / viewWidth) * bounds.width;
|
||||
float selEndX = bounds.x + ((app.timeSelectionEnd - app.viewStart) / viewWidth) * bounds.width;
|
||||
float selStartY = bounds.y + bounds.height - ((app.freqSelectionEnd - app.freqViewStart) / freqWidth) * bounds.height;
|
||||
float selEndY = bounds.y + bounds.height - ((app.freqSelectionStart - app.freqViewStart) / freqWidth) * bounds.height;
|
||||
|
||||
// Clamp to viewport bounds
|
||||
selStartX = fmaxf(bounds.x, fminf(bounds.x + bounds.width, selStartX));
|
||||
selEndX = fmaxf(bounds.x, fminf(bounds.x + bounds.width, selEndX));
|
||||
selStartY = fmaxf(bounds.y, fminf(bounds.y + bounds.height, selStartY));
|
||||
selEndY = fmaxf(bounds.y, fminf(bounds.y + bounds.height, selEndY));
|
||||
|
||||
// Normalize coordinates for drawing
|
||||
float x = selStartX < selEndX ? selStartX : selEndX;
|
||||
float w = fabsf(selEndX - selStartX);
|
||||
float y = selStartY < selEndY ? selStartY : selEndY;
|
||||
float h = fabsf(selEndY - selStartY);
|
||||
|
||||
DrawRectangleLinesEx((Rectangle){ x, y, w, h }, 2, YELLOW);
|
||||
|
||||
// Display live stats while dragging (inside viewport, clamped to fit)
|
||||
{
|
||||
int startSample = (int)(app.timeSelectionStart * app.signal.numSamples);
|
||||
int endSample = (int)(app.timeSelectionEnd * app.signal.numSamples);
|
||||
SignalStats stats = ComputeSignalStats(&app.signal, startSample, endSample);
|
||||
|
||||
if (stats.durationSec > 0.0f && app.signal.samples != NULL) {
|
||||
char lines[5][128];
|
||||
int lineCount = 0;
|
||||
int fontSize = 10;
|
||||
int maxTextW = 0;
|
||||
|
||||
sprintf(lines[lineCount++], "Duration: %.3fs", stats.durationSec);
|
||||
sprintf(lines[lineCount++], "Energy: %.2f", stats.energy);
|
||||
sprintf(lines[lineCount++], "Peak: %.3f", stats.peakAmplitude);
|
||||
sprintf(lines[lineCount++], "RMS: %.3f", stats.rmsAmplitude);
|
||||
sprintf(lines[lineCount++], "PAPR: %.1f dB", stats.paprDb);
|
||||
|
||||
// Measure text width
|
||||
for (int i = 0; i < lineCount; i++) {
|
||||
int textW = MeasureText(lines[i], fontSize);
|
||||
if (textW > maxTextW) maxTextW = textW;
|
||||
}
|
||||
|
||||
int boxW = maxTextW + 20;
|
||||
int boxH = lineCount * 14 + 12;
|
||||
|
||||
// Center vertically on the selection box, clamp to viewport
|
||||
float selCenterY = (selStartY + selEndY) / 2.0f;
|
||||
float boxY = selCenterY - boxH / 2.0f;
|
||||
if (boxY < bounds.y) boxY = bounds.y;
|
||||
if (boxY + boxH > bounds.y + bounds.height) boxY = bounds.y + bounds.height - boxH;
|
||||
|
||||
// Place to the right of the selection, or left if not enough room
|
||||
float boxX = selEndX + 10;
|
||||
if (boxX + boxW > bounds.x + bounds.width) {
|
||||
boxX = selStartX - boxW - 10;
|
||||
if (boxX < bounds.x) boxX = bounds.x;
|
||||
}
|
||||
|
||||
// Clamp to viewport bounds
|
||||
if (boxX + boxW > bounds.x + bounds.width) {
|
||||
// Not enough room on right — draw to the left of selection
|
||||
if (x > boxW + 20) {
|
||||
boxX = x - boxW - 10;
|
||||
} else {
|
||||
boxX = bounds.x;
|
||||
}
|
||||
}
|
||||
if (boxY + boxH > bounds.y + bounds.height) {
|
||||
boxY = bounds.y + bounds.height - boxH;
|
||||
}
|
||||
|
||||
// Draw background box
|
||||
DrawRectangle((int)boxX, (int)boxY, boxW, boxH, (Color){ 0, 0, 0, 200 });
|
||||
DrawRectangleLines((int)boxX, (int)boxY, boxW, boxH, Fade(YELLOW, 0.6f));
|
||||
|
||||
// Draw text
|
||||
for (int i = 0; i < lineCount; i++) {
|
||||
DrawText(lines[i], (int)boxX + 10, (int)boxY + 8 + i * 14, fontSize, LIGHTGRAY);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Playhead
|
||||
// ============================================================================
|
||||
|
||||
void DrawPlayhead(Rectangle bounds)
|
||||
{
|
||||
if (!app.isPlaying || app.playheadT < 0.0f || app.playheadT > 1.0f) return;
|
||||
|
||||
float timePos = app.timeSelectionStart + app.playheadT * (app.timeSelectionEnd - app.timeSelectionStart);
|
||||
float viewWidth = app.viewEnd - app.viewStart;
|
||||
float t = (timePos - app.viewStart) / viewWidth;
|
||||
float x = bounds.x + t * bounds.width;
|
||||
|
||||
// Clamp to bounds
|
||||
x = fmaxf(bounds.x, fminf(bounds.x + bounds.width, x));
|
||||
|
||||
// Draw vertical line
|
||||
DrawLine(x, bounds.y, x, bounds.y + bounds.height, RED);
|
||||
// Draw semi-transparent overlay to make it stand out
|
||||
DrawRectangle(x - 2, bounds.y, 4, bounds.height, (Color){ 255, 0, 0, 60 });
|
||||
}
|
||||
Reference in New Issue
Block a user