From 4dca02daa50e390876e64c7fde0d74d8209977b0 Mon Sep 17 00:00:00 2001 From: iann Date: Thu, 13 Nov 2025 16:12:11 -0600 Subject: [PATCH] first draft of audio fft spectrum visualizer (#5348) --- examples/Makefile | 1 + .../audio/audio_fft_spectrum_visualizer.c | 279 ++++++++++++++++++ .../audio/audio_fft_spectrum_visualizer.png | Bin 0 -> 15580 bytes examples/audio/resources/fft.glsl | 32 ++ 4 files changed, 312 insertions(+) create mode 100644 examples/audio/audio_fft_spectrum_visualizer.c create mode 100644 examples/audio/audio_fft_spectrum_visualizer.png create mode 100644 examples/audio/resources/fft.glsl diff --git a/examples/Makefile b/examples/Makefile index f70e6993d..f36b89bc2 100644 --- a/examples/Makefile +++ b/examples/Makefile @@ -703,6 +703,7 @@ SHADERS = \ shaders/shaders_vertex_displacement AUDIO = \ + audio/audio_fft_spectrum_visualizer \ audio/audio_mixed_processor \ audio/audio_module_playing \ audio/audio_music_stream \ diff --git a/examples/audio/audio_fft_spectrum_visualizer.c b/examples/audio/audio_fft_spectrum_visualizer.c new file mode 100644 index 000000000..ad38020fd --- /dev/null +++ b/examples/audio/audio_fft_spectrum_visualizer.c @@ -0,0 +1,279 @@ +/******************************************************************************************* +* +* raylib [audio] example - fft spectrum visualizer +* +* Example complexity rating: [★★★☆] 3/4 +* +* Example originally created with raylib 6.0 +* +* Inspired by Inigo Quilez's https://www.shadertoy.com/ +* Resources/specification: https://gist.github.com/soulthreads/2efe50da4be1fb5f7ab60ff14ca434b8 +* +* Example created by created by IANN (@meisei4) reviewed by Ramon Santamaria (@raysan5) +* +* Example licensed under an unmodified zlib/libpng license, which is an OSI-certified, +* BSD-like license that allows static linking with closed source software +* +* Copyright (c) 2025 IANN (@meisei4) +* +********************************************************************************************/ + +#include "raylib.h" +#include "raymath.h" +#include +#include +#include + +#define MONO 1 +#define SAMPLE_RATE 44100 +#define SAMPLE_RATE_F 44100.0f +#define FFT_WINDOW_SIZE 1024 +#define BUFFER_SIZE 512 +#define PER_SAMPLE_BIT_DEPTH 16 +#define AUDIO_STREAM_RING_BUFFER_SIZE (FFT_WINDOW_SIZE*2) +#define EFFECTIVE_SAMPLE_RATE (SAMPLE_RATE_F*0.5f) +#define WINDOW_TIME ((double)FFT_WINDOW_SIZE/(double)EFFECTIVE_SAMPLE_RATE) +#define FFT_HISTORICAL_SMOOTHING_DUR 2.0f +#define MIN_DECIBELS (-100.0f) // https://developer.mozilla.org/en-US/docs/Web/API/AnalyserNode/minDecibels +#define MAX_DECIBELS (-30.0f) // https://developer.mozilla.org/en-US/docs/Web/API/AnalyserNode/maxDecibels +#define INVERSE_DECIBEL_RANGE (1.0f/(MAX_DECIBELS - MIN_DECIBELS)) +#define DB_TO_LINEAR_SCALE (20.0f/2.302585092994046f) +#define SMOOTHING_TIME_CONSTANT 0.8f // https://developer.mozilla.org/en-US/docs/Web/API/AnalyserNode/smoothingTimeConstant +#define TEXTURE_HEIGHT 1 +#define FFT_ROW 0 +#define UNUSED_CHANNEL 0.0f + +typedef struct FFTComplex { float real, imaginary; } FFTComplex; + +typedef struct FFTData { + FFTComplex *spectrum; + FFTComplex *workBuffer; + float *prevMagnitudes; + float (*fftHistory)[BUFFER_SIZE]; + int fftHistoryLen; + int historyPos; + double lastFftTime; + float tapbackPos; +} FFTData; + +static void CaptureFrame(FFTData *fftData, const float *audioSamples); +static void RenderFrame(const FFTData *fftData, Image *fftImage); +static void CooleyTukeyFFTSlow(FFTComplex *spectrum, int n); + +//------------------------------------------------------------------------------------ +// Program main entry point +//------------------------------------------------------------------------------------ +int main(void) +{ + // Initialization + //----------------------------------------------------------------------------------- --- + const int screenWidth = 800; + const int screenHeight = 450; + + InitWindow(screenWidth, screenHeight, "raylib [audio] example - fft spectrum visualizer"); + + Image fftImage = GenImageColor(BUFFER_SIZE, TEXTURE_HEIGHT, WHITE); + Texture2D fftTexture = LoadTextureFromImage(fftImage); + RenderTexture2D bufferA = LoadRenderTexture(screenWidth, screenHeight); + Vector2 iResolution = { (float)screenWidth, (float)screenHeight }; + + Shader shader = LoadShader(NULL, "resources/fft.glsl"); + int iResolutionLocation = GetShaderLocation(shader, "iResolution"); + int iChannel0Location = GetShaderLocation(shader, "iChannel0"); + SetShaderValue(shader, iResolutionLocation, &iResolution, SHADER_UNIFORM_VEC2); + SetShaderValueTexture(shader, iChannel0Location, fftTexture); + + InitAudioDevice(); + SetAudioStreamBufferSizeDefault(AUDIO_STREAM_RING_BUFFER_SIZE); + + Wave wav = LoadWave("resources/country.mp3"); + WaveFormat(&wav, SAMPLE_RATE, PER_SAMPLE_BIT_DEPTH, MONO); + + AudioStream audioStream = LoadAudioStream(SAMPLE_RATE, PER_SAMPLE_BIT_DEPTH, MONO); + PlayAudioStream(audioStream); + + int fftHistoryLen = (int)ceilf(FFT_HISTORICAL_SMOOTHING_DUR/WINDOW_TIME) + 1; + + FFTData fft = { + .spectrum = malloc(sizeof(FFTComplex)*FFT_WINDOW_SIZE), + .workBuffer = malloc(sizeof(FFTComplex)*FFT_WINDOW_SIZE), + .prevMagnitudes = calloc(BUFFER_SIZE, sizeof(float)), + .fftHistory = calloc(fftHistoryLen, sizeof(float[BUFFER_SIZE])), + .fftHistoryLen = fftHistoryLen, + .historyPos = 0, + .lastFftTime = 0.0, + .tapbackPos = 0.01f + }; + + size_t wavCursor = 0; + const short *wavPCM16 = wav.data; + + short chunkSamples[AUDIO_STREAM_RING_BUFFER_SIZE] = { 0 }; + float audioSamples[FFT_WINDOW_SIZE] = { 0 }; + + SetTargetFPS(60); + //---------------------------------------------------------------------------------- + + // Main game loop + while (!WindowShouldClose()) // Detect window close button or ESC key + { + // Update + //---------------------------------------------------------------------------------- + while (IsAudioStreamProcessed(audioStream)) + { + for (int i = 0; i < AUDIO_STREAM_RING_BUFFER_SIZE; i++) + { + int left = (wav.channels == 2)? wavPCM16[wavCursor*2 + 0] : wavPCM16[wavCursor]; + int right = (wav.channels == 2)? wavPCM16[wavCursor*2 + 1] : left; + chunkSamples[i] = (short)((left + right)/2); + + if (++wavCursor >= wav.frameCount) + wavCursor = 0; + + } + + UpdateAudioStream(audioStream, chunkSamples, AUDIO_STREAM_RING_BUFFER_SIZE); + + for (int i = 0; i < FFT_WINDOW_SIZE; i++) + audioSamples[i] = (chunkSamples[i*2] + chunkSamples[i*2 + 1])*0.5f/32767.0f; + } + + CaptureFrame(&fft, audioSamples); + RenderFrame(&fft, &fftImage); + UpdateTexture(fftTexture, fftImage.data); + //------------------------------------------------------------------------------ + + // Draw + //---------------------------------------------------------------------------------- + BeginDrawing(); + ClearBackground(BLACK); + BeginShaderMode(shader); + SetShaderValueTexture(shader, iChannel0Location, fftTexture); + DrawTextureRec(bufferA.texture, + (Rectangle){ 0, 0, (float)screenWidth, (float)-screenHeight }, + (Vector2){ 0, 0 }, + WHITE); + EndShaderMode(); + EndDrawing(); + //------------------------------------------------------------------------------ + } + + // De-Initialization + //-------------------------------------------------------------------------------------- + UnloadShader(shader); + UnloadRenderTexture(bufferA); + UnloadTexture(fftTexture); + UnloadImage(fftImage); + UnloadAudioStream(audioStream); + UnloadWave(wav); + CloseAudioDevice(); + + free(fft.spectrum); + free(fft.workBuffer); + free(fft.prevMagnitudes); + free(fft.fftHistory); + + CloseWindow(); // Close window and OpenGL context + //---------------------------------------------------------------------------------- + + return 0; +} + +// Cooley–Tukey FFT https://en.wikipedia.org/wiki/Cooley%E2%80%93Tukey_FFT_algorithm#Data_reordering,_bit_reversal,_and_in-place_algorithms +static void CooleyTukeyFFTSlow(FFTComplex *spectrum, int n) +{ + int j = 0; + for (int i = 1; i < n - 1; i++) + { + int bit = n >> 1; + while (j >= bit) + { + j -= bit; + bit >>= 1; + } + j += bit; + if (i < j) + { + FFTComplex temp = spectrum[i]; + spectrum[i] = spectrum[j]; + spectrum[j] = temp; + } + } + + for (int len = 2; len <= n; len <<= 1) + { + float angle = -2.0f*PI/len; + FFTComplex twiddleUnit = { cosf(angle), sinf(angle) }; + for (int i = 0; i < n; i += len) + { + FFTComplex twiddleCurrent = { 1.0f, 0.0f }; + for (int j = 0; j < len/2; j++) + { + FFTComplex even = spectrum[i + j]; + FFTComplex odd = spectrum[i + j + len/2]; + FFTComplex twiddledOdd = { + odd.real*twiddleCurrent.real - odd.imaginary*twiddleCurrent.imaginary, + odd.real*twiddleCurrent.imaginary + odd.imaginary*twiddleCurrent.real + }; + + spectrum[i + j].real = even.real + twiddledOdd.real; + spectrum[i + j].imaginary = even.imaginary + twiddledOdd.imaginary; + spectrum[i + j + len/2].real = even.real - twiddledOdd.real; + spectrum[i + j + len/2].imaginary = even.imaginary - twiddledOdd.imaginary; + + float twiddleRealNext = twiddleCurrent.real*twiddleUnit.real - twiddleCurrent.imaginary*twiddleUnit.imaginary; + twiddleCurrent.imaginary = twiddleCurrent.real*twiddleUnit.imaginary + twiddleCurrent.imaginary*twiddleUnit.real; + twiddleCurrent.real = twiddleRealNext; + } + } + } +} + +static void CaptureFrame(FFTData *fftData, const float *audioSamples) +{ + for (int i = 0; i < FFT_WINDOW_SIZE; i++) + { + float x = (2.0f*PI*i)/(FFT_WINDOW_SIZE - 1.0f); + float blackmanWeight = 0.42f - 0.5f*cosf(x) + 0.08f*cosf(2.0f*x); // https://en.wikipedia.org/wiki/Window_function#Blackman_window + fftData->workBuffer[i].real = audioSamples[i]*blackmanWeight; + fftData->workBuffer[i].imaginary = 0.0f; + } + + CooleyTukeyFFTSlow(fftData->workBuffer, FFT_WINDOW_SIZE); + memcpy(fftData->spectrum, fftData->workBuffer, sizeof(FFTComplex)*FFT_WINDOW_SIZE); + + float smoothedSpectrum[BUFFER_SIZE]; + + for (int bin = 0; bin < BUFFER_SIZE; bin++) + { + float re = fftData->workBuffer[bin].real; + float im = fftData->workBuffer[bin].imaginary; + float linearMagnitude = sqrtf(re*re + im*im)/FFT_WINDOW_SIZE; + + float smoothedMagnitude = SMOOTHING_TIME_CONSTANT*fftData->prevMagnitudes[bin] + (1.0f - SMOOTHING_TIME_CONSTANT)*linearMagnitude; + fftData->prevMagnitudes[bin] = smoothedMagnitude; + + float db = logf(fmaxf(smoothedMagnitude, 1e-40f))*DB_TO_LINEAR_SCALE; + float normalized = (db - MIN_DECIBELS)*INVERSE_DECIBEL_RANGE; + smoothedSpectrum[bin] = Clamp(normalized, 0.0f, 1.0f); + } + + fftData->lastFftTime = GetTime(); + memcpy(fftData->fftHistory[fftData->historyPos], smoothedSpectrum, sizeof(smoothedSpectrum)); + fftData->historyPos = (fftData->historyPos + 1) % fftData->fftHistoryLen; +} + +static void RenderFrame(const FFTData *fftData, Image *fftImage) +{ + double framesSinceTapback = floor(fftData->tapbackPos/WINDOW_TIME); + framesSinceTapback = Clamp(framesSinceTapback, 0.0, fftData->fftHistoryLen - 1); + + int historyPosition = (fftData->historyPos - 1 - (int)framesSinceTapback) % fftData->fftHistoryLen; + if (historyPosition < 0) + historyPosition += fftData->fftHistoryLen; + + const float *amplitude = fftData->fftHistory[historyPosition]; + for (int bin = 0; bin < BUFFER_SIZE; bin++) { + ImageDrawPixel(fftImage, bin, FFT_ROW, ColorFromNormalized((Vector4){ amplitude[bin], UNUSED_CHANNEL, UNUSED_CHANNEL, UNUSED_CHANNEL })); + } +} \ No newline at end of file diff --git a/examples/audio/audio_fft_spectrum_visualizer.png b/examples/audio/audio_fft_spectrum_visualizer.png new file mode 100644 index 0000000000000000000000000000000000000000..c3f1bc8b0acfbc066c241358f06922dcc8133e86 GIT binary patch literal 15580 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 XmXUW`GFsuSWx