spectrum visualizer
There is no browser build of this example yet.
Is it the same as C?
- Desktop:builds The Zig version builds, but it is not compared with C, because the C example does not give the same frame twice: self-nondeterministic (C vs C differs 2 of 3 runs): spectrum of live audio-thread output.
- Browser:not checked yet The browser comparison does not cover this example yet.
//! raylib [audio] example - spectrum visualizer
//!
//! Zig-API translation of examples/audio/audio_spectrum_visualizer.c (raylib 6.2).
//! 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.
//! Copyright (c) 2025 IANN (@meisei4)
const std = @import("std");
const rl = @import("raylib");
const glsl_version = 330;
const mono = 1;
const sample_rate = 44100;
const sample_rate_f: f32 = 44100.0;
const fft_window_size = 1024;
const buffer_size = 512;
const per_sample_bit_depth = 16;
const audio_stream_ring_buffer_size = fft_window_size * 2;
const effective_sample_rate: f32 = sample_rate_f * 0.5;
const window_time: f64 = @as(f64, fft_window_size) / @as(f64, effective_sample_rate);
const fft_historical_smoothing_dur: f32 = 2.0;
const min_decibels: f32 = -100.0; // https://developer.mozilla.org/en-US/docs/Web/API/AnalyserNode/minDecibels
const max_decibels: f32 = -30.0; // https://developer.mozilla.org/en-US/docs/Web/API/AnalyserNode/maxDecibels
const inverse_decibel_range: f32 = 1.0 / (max_decibels - min_decibels);
const db_to_linear_scale: f32 = 20.0 / @as(f32, 2.302585092994046);
const smoothing_time_constant: f32 = 0.8; // https://developer.mozilla.org/en-US/docs/Web/API/AnalyserNode/smoothingTimeConstant
const texture_height = 1;
const fft_row = 0;
const unused_channel: f32 = 0.0;
const fft_history_len: comptime_int = @as(i32, @intFromFloat(@ceil(@as(f32, @floatCast(fft_historical_smoothing_dur / window_time))))) + 1;
const FftComplex = struct { real: f32, imaginary: f32 };
const FftData = struct {
spectrum: [fft_window_size]FftComplex = @splat(.{ .real = 0, .imaginary = 0 }),
work_buffer: [fft_window_size]FftComplex = @splat(.{ .real = 0, .imaginary = 0 }),
prev_magnitudes: [buffer_size]f32 = @splat(0),
fft_history: [fft_history_len][buffer_size]f32 = @splat(@splat(0)),
history_pos: usize = 0,
last_fft_time: f64 = 0.0,
tapback_pos: f32 = 0.01,
};
pub fn main() !void {
// Initialization
const screen_width = 800;
const screen_height = 450;
rl.initWindow(screen_width, screen_height, "raylib [audio] example - spectrum visualizer");
defer rl.closeWindow(); // Close window and OpenGL context
var fft_image = rl.genImageColor(buffer_size, texture_height, .white);
defer rl.unloadImage(fft_image);
const fft_texture = try rl.loadTextureFromImage(fft_image);
defer rl.unloadTexture(fft_texture);
const buffer_a = try rl.loadRenderTexture(screen_width, screen_height);
defer rl.unloadRenderTexture(buffer_a);
const i_resolution: rl.Vector2 = .{ .x = screen_width, .y = screen_height };
const shader = try rl.loadShader(null, std.fmt.comptimePrint("resources/shaders/glsl{d}/fft.fs", .{glsl_version}));
defer rl.unloadShader(shader);
const i_resolution_location = rl.getShaderLocation(shader, "iResolution");
const i_channel0_location = rl.getShaderLocation(shader, "iChannel0");
rl.setShaderValue(shader, i_resolution_location, i_resolution);
rl.setShaderValueTexture(shader, i_channel0_location, fft_texture);
rl.initAudioDevice();
defer rl.closeAudioDevice();
rl.setAudioStreamBufferSizeDefault(audio_stream_ring_buffer_size);
var wav = try rl.loadWave("resources/country.mp3");
defer rl.unloadWave(wav);
rl.waveFormat(&wav, sample_rate, per_sample_bit_depth, mono);
const audio_stream = try rl.loadAudioStream(sample_rate, per_sample_bit_depth, mono);
defer rl.unloadAudioStream(audio_stream);
rl.playAudioStream(audio_stream);
var fft: FftData = .{};
const wav_pcm16 = @as([*]const i16, @ptrCast(@alignCast(wav.data.?)))[0 .. wav.frameCount * wav.channels];
var wav_cursor: usize = 0;
var chunk_samples: [audio_stream_ring_buffer_size]i16 = @splat(0);
var audio_samples: [fft_window_size]f32 = @splat(0);
rl.setTargetFPS(60);
// Main game loop
while (!rl.windowShouldClose()) { // Detect window close button or ESC key
// Update
while (rl.isAudioStreamProcessed(audio_stream)) {
for (&chunk_samples) |*sample| {
const left: i32 = if (wav.channels == 2) wav_pcm16[wav_cursor * 2 + 0] else wav_pcm16[wav_cursor];
const right: i32 = if (wav.channels == 2) wav_pcm16[wav_cursor * 2 + 1] else left;
sample.* = @intCast(@divTrunc(left + right, 2));
wav_cursor += 1;
if (wav_cursor >= wav.frameCount) wav_cursor = 0;
}
rl.updateAudioStream(audio_stream, &chunk_samples, audio_stream_ring_buffer_size);
for (&audio_samples, 0..) |*sample, i| {
const sum: f32 = @floatFromInt(@as(i32, chunk_samples[i * 2]) + chunk_samples[i * 2 + 1]);
sample.* = sum * 0.5 / 32767.0;
}
}
captureFrame(&fft, &audio_samples);
renderFrame(&fft, &fft_image);
rl.updateTexture(fft_texture, fft_image.data.?);
// Draw
rl.beginDrawing();
defer rl.endDrawing();
rl.clearBackground(.ray_white);
{
rl.beginShaderMode(shader);
defer rl.endShaderMode();
rl.setShaderValueTexture(shader, i_channel0_location, fft_texture);
rl.drawTextureRec(
buffer_a.texture,
.{ .x = 0, .y = 0, .width = screen_width, .height = -screen_height },
.{ .x = 0, .y = 0 },
.white,
);
}
}
}
/// Cooley–Tukey FFT https://en.wikipedia.org/wiki/Cooley%E2%80%93Tukey_FFT_algorithm#Data_reordering,_bit_reversal,_and_in-place_algorithms
fn cooleyTukeyFftSlow(spectrum: []FftComplex) void {
const n = spectrum.len;
var j: usize = 0;
for (1..n - 1) |i| {
var bit = n >> 1;
while (j >= bit) {
j -= bit;
bit >>= 1;
}
j += bit;
if (i < j) std.mem.swap(FftComplex, &spectrum[i], &spectrum[j]);
}
var len: usize = 2;
while (len <= n) : (len <<= 1) {
const angle = -2.0 * rl.pi / @as(f32, @floatFromInt(len));
const twiddle_unit: FftComplex = .{ .real = @cos(angle), .imaginary = @sin(angle) };
const half = len / 2;
var i: usize = 0;
while (i < n) : (i += len) {
var twiddle_current: FftComplex = .{ .real = 1.0, .imaginary = 0.0 };
for (spectrum[i..][0..half], spectrum[i + half ..][0..half]) |*even_slot, *odd_slot| {
const even = even_slot.*;
const odd = odd_slot.*;
const twiddled_odd: FftComplex = .{
.real = odd.real * twiddle_current.real - odd.imaginary * twiddle_current.imaginary,
.imaginary = odd.real * twiddle_current.imaginary + odd.imaginary * twiddle_current.real,
};
even_slot.* = .{ .real = even.real + twiddled_odd.real, .imaginary = even.imaginary + twiddled_odd.imaginary };
odd_slot.* = .{ .real = even.real - twiddled_odd.real, .imaginary = even.imaginary - twiddled_odd.imaginary };
const t = twiddle_current;
twiddle_current = .{
.real = t.real * twiddle_unit.real - t.imaginary * twiddle_unit.imaginary,
.imaginary = t.real * twiddle_unit.imaginary + t.imaginary * twiddle_unit.real,
};
}
}
}
}
fn captureFrame(fft_data: *FftData, audio_samples: *const [fft_window_size]f32) void {
for (&fft_data.work_buffer, audio_samples, 0..) |*bin, sample, i| {
const x = (2.0 * rl.pi * @as(f32, @floatFromInt(i))) / (fft_window_size - 1.0);
const blackman_weight = 0.42 - 0.5 * @cos(x) + 0.08 * @cos(2.0 * x); // https://en.wikipedia.org/wiki/Window_function#Blackman_window
bin.* = .{ .real = sample * blackman_weight, .imaginary = 0.0 };
}
cooleyTukeyFftSlow(&fft_data.work_buffer);
fft_data.spectrum = fft_data.work_buffer;
var smoothed_spectrum: [buffer_size]f32 = undefined;
for (&smoothed_spectrum, &fft_data.prev_magnitudes, fft_data.work_buffer[0..buffer_size]) |*smoothed, *prev_magnitude, bin| {
const linear_magnitude = @sqrt(bin.real * bin.real + bin.imaginary * bin.imaginary) / fft_window_size;
const smoothed_magnitude = smoothing_time_constant * prev_magnitude.* + (1.0 - smoothing_time_constant) * linear_magnitude;
prev_magnitude.* = smoothed_magnitude;
const db = @log(@max(smoothed_magnitude, 1e-40)) * db_to_linear_scale;
const normalized = (db - min_decibels) * inverse_decibel_range;
smoothed.* = rl.math.clamp(normalized, 0.0, 1.0);
}
fft_data.last_fft_time = rl.getTime();
fft_data.fft_history[fft_data.history_pos] = smoothed_spectrum;
fft_data.history_pos = (fft_data.history_pos + 1) % fft_history_len;
}
fn renderFrame(fft_data: *const FftData, fft_image: *rl.Image) void {
var frames_since_tapback = @floor(@as(f32, @floatCast(fft_data.tapback_pos / window_time)));
frames_since_tapback = rl.math.clamp(frames_since_tapback, 0.0, fft_history_len - 1);
const history_len: i32 = fft_history_len;
var history_position = @rem(@as(i32, @intCast(fft_data.history_pos)) - 1 - @as(i32, @intFromFloat(frames_since_tapback)), history_len);
if (history_position < 0) history_position += history_len;
const amplitude = &fft_data.fft_history[@intCast(history_position)];
for (amplitude, 0..) |value, bin| {
const color = rl.colorFromNormalized(.{ .x = value, .y = unused_channel, .z = unused_channel, .w = unused_channel });
rl.imageDrawPixel(fft_image, @intCast(bin), fft_row, color);
}
}/*******************************************************************************************
*
* raylib [audio] example - spectrum visualizer
*
* Example complexity rating: [★★★☆] 3/4
*
* Example originally created with raylib 6.0, last time updated 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 <math.h>
#include <stdlib.h>
#include <string.h>
#if defined(PLATFORM_DESKTOP)
#define GLSL_VERSION 330
#else // PLATFORM_ANDROID, PLATFORM_WEB
#define GLSL_VERSION 100
#endif
#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 - 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(0, TextFormat("resources/shaders/glsl%i/fft.fs", GLSL_VERSION));
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);
// WARNING: Memory out-of-bounds on PLATFORM_WEB
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 = RL_CALLOC(sizeof(FFTComplex), FFT_WINDOW_SIZE),
.workBuffer = RL_CALLOC(sizeof(FFTComplex), FFT_WINDOW_SIZE),
.prevMagnitudes = RL_CALLOC(BUFFER_SIZE, sizeof(float)),
.fftHistory = RL_CALLOC(fftHistoryLen, sizeof(float[BUFFER_SIZE])),
.fftHistoryLen = fftHistoryLen,
.historyPos = 0,
.lastFftTime = 0.0,
.tapbackPos = 0.01f
};
unsigned int 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(RAYWHITE);
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();
RL_FREE(fft.spectrum);
RL_FREE(fft.workBuffer);
RL_FREE(fft.prevMagnitudes);
RL_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)
{
float framesSinceTapback = floorf((float)(fftData->tapbackPos/WINDOW_TIME));
framesSinceTapback = Clamp(framesSinceTapback, 0.0f, (float)(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 }));
}From raylib 6.2. The original on GitHub.