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);
|
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;
|
||||||
@@ -207,13 +212,53 @@ void PlaySelectedRegion(void)
|
|||||||
ApplyBandpassFilter(regionSamples, numSamples, app.signal.sampleRate, freqLow, freqHigh);
|
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);
|
if (AudioPlaybackSound.frameCount != 0) UnloadSound(AudioPlaybackSound);
|
||||||
|
|
||||||
Wave wave = { .data = regionSamples, .frameCount = (unsigned int)numSamples,
|
Wave wave = { .data = regionSamples, .frameCount = (unsigned int)numSamples,
|
||||||
.sampleRate = (unsigned int)app.signal.sampleRate, .sampleSize = 32, .channels = 1 };
|
.sampleRate = (unsigned int)app.signal.sampleRate, .sampleSize = 32, .channels = 1 };
|
||||||
AudioPlaybackSound = LoadSoundFromWave(wave);
|
AudioPlaybackSound = LoadSoundFromWave(wave); // copies the samples
|
||||||
PlaySound(AudioPlaybackSound);
|
PlaySound(AudioPlaybackSound);
|
||||||
TraceLog(LOG_INFO, "Playing: %.2f-%.2f sec, %.0f-%.0f Hz",
|
free(regionSamples);
|
||||||
(float)startSample / app.signal.sampleRate, (float)endSample / app.signal.sampleRate, freqLow, freqHigh);
|
}
|
||||||
|
|
||||||
|
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).
|
// 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
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user