An audio-reactive video generator using ModernGL and ffmpeg.
av.py renders a fragment shader for every frame of an audio file, analyzes the audio with librosa (RMS, bands, onset, beat, spectral centroid, and more), streams RGB frames to ffmpeg, and muxes H.264 + AAC into an MP4.
- Python 3 with
venv - ffmpeg on your
PATH(the script shells out to encode video) - Optional: a still image (PNG/JPEG, etc.) when you want the shader to sample a texture instead of a black canvas
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txtTwo modes, controlled by how many positional arguments you pass:
-
Image + audio → video — resolution follows the image; the shader receives it as
sampler2D u_tex(when the shader declares that uniform).python av.py photo.jpg track.wav outputs/out.mp4
-
Audio only → video — solid black RGB buffer at
--width×--height(default 1280×720). Use this for shaders that do not need a source image.python av.py track.wav outputs/out.mp4
| Flag | Default | Meaning |
|---|---|---|
--fps |
30 |
Frame rate; also drives audio feature hop length. |
--shader |
shaders/sliding_bars.glsl |
Fragment shader path. |
--vertex-shader |
shaders/vertex.glsl |
Vertex shader path. |
--width, --height |
1280, 720 |
Canvas size in audio-only mode (ignored when an image is provided). |
--grid |
1 |
Subdivisions of a clip-space triangle mesh (1 = one fullscreen quad). Higher values feed a denser mesh to the vertex shader for deformation. |
--flip-vertical |
off | Flip each frame vertically after readback so “up” in the shader matches “up” in the encoded file (OpenGL’s origin is bottom-left). |
--ripples |
off | Blank canvas only + --shader shaders/pin_impression_bw.glsl: center ripples (water-stone) pin heights (u_pin_pattern = 1). Mutually exclusive with --waves. |
--waves |
off | Same pairing: smooth interference-wave pin heights (u_pin_pattern = 2). |
Pin board examples with image input:
python av.py flower.jpg song.wav outputs/flower_pins.mp4 \
--shader shaders/pin_impression.glsl \Black-and-white pin board (luminance only, same idea as pin_impression.glsl). With an image, av.py uploads Rec.709 grayscale (same weights as the shader) so the texture is black-and-white while pin length still follows luminance.
python av.py photo.png music.wav outputs/bw.mp4 \
--shader shaders/pin_impression_bw.glsl \Blank canvas + procedural pins (no image; still pass <audio> <output>):
python av.py music.wav outputs/pins_ripples.mp4 \
--shader shaders/pin_impression_bw.glsl \
--ripples
python av.py music.wav outputs/pins_waves.mp4 \
--shader shaders/pin_impression_bw.glsl \
--wavesThe Python side sets u_time (seconds) every frame. It also sets optional uniforms only if your shader declares them:
u_audio— normalized RMS envelopeu_bass,u_mids,u_treble— band energiesu_onset— onset strengthu_beat,u_bpm_phase— beat pulses and phase vs. estimated tempou_centroid,u_flatness— spectral descriptorsu_harmonic,u_percussive— HPSS splitu_pin_pattern— used bypin_impression_bw.glsl:0texture luma,1ripples,2waves (set from--ripples/--wavesin blank-canvas mode).u_reveal_mask_flip—0default;1with--organic-mask-flipinverts vertical crawl formandala_organic_reveal.glsl.
Other fragment shaders in shaders/ include character_pulse.glsl, rectangles.glsl, tethered_nodes.glsl, organic_reveal.glsl, and others; try them with --shader.
python av.py photo.jpg track.wav outputs/organic_reveal.mp4 \
--shader shaders/organic_reveal.glslDefault shaders/vertex.glsl passes clip-space positions through. For motion tied to the mesh, you can point --vertex-shader at something like shaders/vertex.glsl (if your shader pair expects the same varyings).
Rendering writes a temporary temp.mp4 in the working directory, then renames it to your output path. Ensure you have write permission there and enough disk space for the encode.