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:
+51
-6
@@ -187,16 +187,21 @@ 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;
|
||||
@@ -207,13 +212,53 @@ void PlaySelectedRegion(void)
|
||||
ApplyBandpassFilter(regionSamples, numSamples, 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);
|
||||
AudioPlaybackSound = LoadSoundFromWave(wave); // copies the samples
|
||||
PlaySound(AudioPlaybackSound);
|
||||
TraceLog(LOG_INFO, "Playing: %.2f-%.2f sec, %.0f-%.0f Hz",
|
||||
(float)startSample / app.signal.sampleRate, (float)endSample / app.signal.sampleRate, freqLow, freqHigh);
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user