From 542126261e98fcd2130682938992abd4fe076e24 Mon Sep 17 00:00:00 2001 From: Tyler Date: Mon, 25 May 2026 10:42:37 -0700 Subject: [PATCH] feat: export selection to WAV (band-limited, time-cropped) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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_-s_-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 --- src/audio.c | 75 ++++++++++++++++++++++++++++++++--------- src/audio.h | 4 +++ src/spectrogram.c | 9 +++-- src/spectrogram_types.h | 1 + src/ui.c | 10 ++++++ 5 files changed, 82 insertions(+), 17 deletions(-) diff --git a/src/audio.c b/src/audio.c index f600b11..3259f22 100644 --- a/src/audio.c +++ b/src/audio.c @@ -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); } diff --git a/src/audio.h b/src/audio.h index 20f8d96..50a3d8e 100644 --- a/src/audio.h +++ b/src/audio.h @@ -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 diff --git a/src/spectrogram.c b/src/spectrogram.c index 34ba1b1..71fa197 100644 --- a/src/spectrogram.c +++ b/src/spectrogram.c @@ -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(); } diff --git a/src/spectrogram_types.h b/src/spectrogram_types.h index a07358c..f4c888b 100644 --- a/src/spectrogram_types.h +++ b/src/spectrogram_types.h @@ -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; diff --git a/src/ui.c b/src/ui.c index 1e59e56..3e01f32 100644 --- a/src/ui.c +++ b/src/ui.c @@ -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 };