feat: export selection to WAV (band-limited, time-cropped)

New "Export WAV (W)" button + W key saves the current selection as a mono
WAV — the same band-limited, time-cropped audio you'd hear on playback, so
"what you hear is what you save". Self-documenting filename encodes the
time span and frequency band (rspektrum_sel_<t0>-<t1>s_<f0>-<f1>Hz.wav).

Refactor the shared filtered-region builder out of PlaySelectedRegion
(which also fixes a leak: it never freed regionSamples after
LoadSoundFromWave copies it). Make the export confirmation message persist
~3s instead of flashing for a single frame (affected PNG export too).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 10:42:37 -07:00
parent 9d21dbe82b
commit 542126261e
5 changed files with 82 additions and 17 deletions
+60 -15
View File
@@ -187,18 +187,23 @@ static void ApplyBandpassFilter(float* samples, int numSamples, int sampleRate,
ApplyBandpassFilterFFT(samples, numSamples, sampleRate, freqLow, freqHigh); ApplyBandpassFilterFFT(samples, numSamples, sampleRate, freqLow, freqHigh);
} }
void PlaySelectedRegion(void) // Build the current selection's audio: the samples in the selected time span,
// band-limited to the selected frequency box (same processing as playback, so
// "what you hear is what you save"). Returns a malloc'd mono float buffer the
// caller must free, with *outNumSamples set; NULL if there's no valid region.
static float* BuildSelectionAudio(int* outNumSamples)
{ {
if (!app.loaded || !app.stftComputed) return; if (!app.loaded || !app.stftComputed) return NULL;
int startSample = (int)(app.sel.timeStart * app.signal.numSamples); int startSample = (int)(app.sel.timeStart * app.signal.numSamples);
int endSample = (int)(app.sel.timeEnd * app.signal.numSamples); int endSample = (int)(app.sel.timeEnd * app.signal.numSamples);
int numSamples = endSample - startSample; int numSamples = endSample - startSample;
if (numSamples <= 0 || startSample < 0 || endSample > app.signal.numSamples) return; if (numSamples <= 0 || startSample < 0 || endSample > app.signal.numSamples) return NULL;
float* regionSamples = (float*)malloc(numSamples * sizeof(float)); float* regionSamples = (float*)malloc(numSamples * sizeof(float));
if (!regionSamples) return NULL;
memcpy(regionSamples, app.signal.samples + startSample, numSamples * sizeof(float)); memcpy(regionSamples, app.signal.samples + startSample, numSamples * sizeof(float));
float maxFreq = (float)app.signal.sampleRate / 2.0f; float maxFreq = (float)app.signal.sampleRate / 2.0f;
float freqLow = app.sel.freqStart * maxFreq; float freqLow = app.sel.freqStart * maxFreq;
float freqHigh = app.sel.freqEnd * maxFreq; float freqHigh = app.sel.freqEnd * maxFreq;
@@ -206,14 +211,54 @@ void PlaySelectedRegion(void)
TraceLog(LOG_INFO, "Applying bandpass filter: %.0f - %.0f Hz", freqLow, freqHigh); TraceLog(LOG_INFO, "Applying bandpass filter: %.0f - %.0f Hz", freqLow, freqHigh);
ApplyBandpassFilter(regionSamples, numSamples, app.signal.sampleRate, freqLow, freqHigh); ApplyBandpassFilter(regionSamples, numSamples, app.signal.sampleRate, freqLow, freqHigh);
} }
if (AudioPlaybackSound.frameCount != 0) UnloadSound(AudioPlaybackSound); *outNumSamples = numSamples;
return regionSamples;
Wave wave = { .data = regionSamples, .frameCount = (unsigned int)numSamples, }
.sampleRate = (unsigned int)app.signal.sampleRate, .sampleSize = 32, .channels = 1 };
AudioPlaybackSound = LoadSoundFromWave(wave); void PlaySelectedRegion(void)
PlaySound(AudioPlaybackSound); {
TraceLog(LOG_INFO, "Playing: %.2f-%.2f sec, %.0f-%.0f Hz", int numSamples = 0;
(float)startSample / app.signal.sampleRate, (float)endSample / app.signal.sampleRate, freqLow, freqHigh); float* regionSamples = BuildSelectionAudio(&numSamples);
if (!regionSamples) return;
if (AudioPlaybackSound.frameCount != 0) UnloadSound(AudioPlaybackSound);
Wave wave = { .data = regionSamples, .frameCount = (unsigned int)numSamples,
.sampleRate = (unsigned int)app.signal.sampleRate, .sampleSize = 32, .channels = 1 };
AudioPlaybackSound = LoadSoundFromWave(wave); // copies the samples
PlaySound(AudioPlaybackSound);
free(regionSamples);
}
void ExportSelectionWAV(const char* dirPath)
{
int numSamples = 0;
float* samples = BuildSelectionAudio(&numSamples);
if (!samples) {
snprintf(app.exportMessage, sizeof(app.exportMessage), "No region to export");
app.exportMessageTimer = 3.0f;
return;
}
float maxFreq = (float)app.signal.sampleRate / 2.0f;
float t0 = app.sel.timeStart * app.signal.duration;
float t1 = app.sel.timeEnd * app.signal.duration;
float f0 = app.sel.freqStart * maxFreq;
float f1 = app.sel.freqEnd * maxFreq;
char path[4608];
snprintf(path, sizeof(path), "%s/rspektrum_sel_%.0f-%.0fs_%.0f-%.0fHz.wav",
dirPath, t0, t1, f0, f1);
Wave wave = { .data = samples, .frameCount = (unsigned int)numSamples,
.sampleRate = (unsigned int)app.signal.sampleRate, .sampleSize = 32, .channels = 1 };
if (ExportWave(wave, path))
snprintf(app.exportMessage, sizeof(app.exportMessage), "Saved WAV: %.40s", path);
else
snprintf(app.exportMessage, sizeof(app.exportMessage), "WAV export failed");
app.exportMessageTimer = 3.0f;
free(samples);
} }
+4
View File
@@ -12,4 +12,8 @@ void FreeSignal(AudioSignal* signal);
// Play the current time/frequency selection (applies an FFT bandpass filter). // Play the current time/frequency selection (applies an FFT bandpass filter).
void PlaySelectedRegion(void); void PlaySelectedRegion(void);
// Save the current selection (band-limited, time-cropped — same processing as
// playback) as a mono WAV in dirPath. Reports the result via app.exportMessage.
void ExportSelectionWAV(const char* dirPath);
#endif // AUDIO_H #endif // AUDIO_H
+7 -2
View File
@@ -154,6 +154,7 @@ static void ActionToggleScope(void) { app.showScope = !app.showScope; }
static void ActionToggleAbout(void) { app.showAbout = !app.showAbout; } 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 ActionResetView(void) static void ActionResetView(void)
{ {
@@ -177,6 +178,7 @@ static const KeyBinding KEYMAP[] = {
{ KEY_HOME, KEYGATE_MODAL | KEYGATE_LOADED,ActionResetView, "Home", "reset view (fit all)" }, { KEY_HOME, KEYGATE_MODAL | KEYGATE_LOADED,ActionResetView, "Home", "reset view (fit all)" },
{ 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" },
// 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" },
@@ -1062,8 +1064,11 @@ int main(int argc, char* argv[])
DrawText(app.exportMessage, boxX + (boxW - msgW) / 2, boxY + 10, 20, WHITE); DrawText(app.exportMessage, boxX + (boxW - msgW) / 2, boxY + 10, 20, WHITE);
} }
// Reset export message after one frame display // Hold the export message for a few seconds, then clear it.
app.exportMessage[0] = '\0'; if (app.exportMessageTimer > 0.0f) {
app.exportMessageTimer -= GetFrameTime();
if (app.exportMessageTimer <= 0.0f) app.exportMessage[0] = '\0';
}
EndDrawing(); EndDrawing();
} }
+1
View File
@@ -136,6 +136,7 @@ typedef struct {
float exportScale; float exportScale;
char exportDir[4096]; char exportDir[4096];
char exportMessage[256]; char exportMessage[256];
float exportMessageTimer; // seconds the export message stays on screen
// Visible viewport (time + frequency) and in-progress pan state. // Visible viewport (time + frequency) and in-progress pan state.
Viewport view; Viewport view;
+10
View File
@@ -323,6 +323,7 @@ void ExportPNG(const SpectrogramApp* spa, const char* dirPath)
} else { } else {
snprintf((char*)spa->exportMessage, sizeof(spa->exportMessage), "Export failed"); snprintf((char*)spa->exportMessage, sizeof(spa->exportMessage), "Export failed");
} }
((SpectrogramApp*)spa)->exportMessageTimer = 3.0f;
RL_FREE(region.data); RL_FREE(region.data);
} }
@@ -484,6 +485,15 @@ void DrawSidebar(void)
DrawTextScaled("Export PNG (E)", exportButton.x + 10 * scale, exportButton.y + 7 * scale, 14, WHITE); DrawTextScaled("Export PNG (E)", exportButton.x + 10 * scale, exportButton.y + 7 * scale, 14, WHITE);
y += 35 * scale; y += 35 * scale;
// Export selection audio (band-limited, time-cropped) as a WAV
Rectangle wavButton = { x, y, sidebarWidth - 10 * scale, 30 * scale };
if (Clicked(wavButton)) {
ExportSelectionWAV(app.exportDir);
}
DrawPanelBox(wavButton, (Color){ 40, 60, 80, 255 }, CYAN);
DrawTextScaled("Export WAV (W)", wavButton.x + 10 * scale, wavButton.y + 7 * scale, 14, WHITE);
y += 35 * scale;
// Export scale slider // Export scale slider
DrawTextScaled(TextFormat("Export Scale: %.1fx", app.exportScale), x, y, 12, LIGHTGRAY); y += 18 * scale; DrawTextScaled(TextFormat("Export Scale: %.1fx", app.exportScale), x, y, 12, LIGHTGRAY); y += 18 * scale;
Rectangle scaleSlider = { x, y, sidebarWidth - 10 * scale, 14 * scale }; Rectangle scaleSlider = { x, y, sidebarWidth - 10 * scale, 14 * scale };