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);
}
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 endSample = (int)(app.sel.timeEnd * app.signal.numSamples);
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));
if (!regionSamples) return NULL;
memcpy(regionSamples, app.signal.samples + startSample, numSamples * sizeof(float));
float maxFreq = (float)app.signal.sampleRate / 2.0f;
float freqLow = app.sel.freqStart * 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);
ApplyBandpassFilter(regionSamples, numSamples, app.signal.sampleRate, freqLow, freqHigh);
}
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);
PlaySound(AudioPlaybackSound);
TraceLog(LOG_INFO, "Playing: %.2f-%.2f sec, %.0f-%.0f Hz",
(float)startSample / app.signal.sampleRate, (float)endSample / app.signal.sampleRate, freqLow, freqHigh);
*outNumSamples = numSamples;
return regionSamples;
}
void PlaySelectedRegion(void)
{
int numSamples = 0;
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).
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
+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 ActionToggleFullscreen(void){ ToggleFullscreen(); }
static void ActionExport(void) { ExportPNG(&app, app.exportDir); }
static void ActionExportWav(void) { ExportSelectionWAV(app.exportDir); }
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_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" },
// 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" },
@@ -1062,8 +1064,11 @@ int main(int argc, char* argv[])
DrawText(app.exportMessage, boxX + (boxW - msgW) / 2, boxY + 10, 20, WHITE);
}
// Reset export message after one frame display
app.exportMessage[0] = '\0';
// Hold the export message for a few seconds, then clear it.
if (app.exportMessageTimer > 0.0f) {
app.exportMessageTimer -= GetFrameTime();
if (app.exportMessageTimer <= 0.0f) app.exportMessage[0] = '\0';
}
EndDrawing();
}
+1
View File
@@ -136,6 +136,7 @@ typedef struct {
float exportScale;
char exportDir[4096];
char exportMessage[256];
float exportMessageTimer; // seconds the export message stays on screen
// Visible viewport (time + frequency) and in-progress pan state.
Viewport view;
+10
View File
@@ -323,6 +323,7 @@ void ExportPNG(const SpectrogramApp* spa, const char* dirPath)
} else {
snprintf((char*)spa->exportMessage, sizeof(spa->exportMessage), "Export failed");
}
((SpectrogramApp*)spa)->exportMessageTimer = 3.0f;
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);
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
DrawTextScaled(TextFormat("Export Scale: %.1fx", app.exportScale), x, y, 12, LIGHTGRAY); y += 18 * scale;
Rectangle scaleSlider = { x, y, sidebarWidth - 10 * scale, 14 * scale };