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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