Betatron
Watch on YouTubeA second synthwave track
My second synthwave track made entirely using free software:
- LMMS for MIDI editing, effects,
- zynaddsubfx for the sounds,
- Audacity for normalization,
- Python with Cairo library for the visuals,
- ffmpeg for the final video encoding.
The code
First, the necessary imports.
import sys
import cairo # graphics library
import math # for some constants
import random # random for some creativity
import utils # a helper module mainly for ffmpeg
import numpy as np # for fast Fourier transform mainly
from scipy.io import wavfile # for wav file parsing
Fast Fourier transform
def get_fft_bins(leftslot, rightslot, spf, rate):
lslot = leftslot * np.hamming(len(leftslot))
rslot = rightslot * np.hamming(len(rightslot))
fftx = np.arange(int(spf / 2), dtype=float) * rate / spf
fftx_bin_indices = (
np.logspace(
np.log2(len(fftx)),
0,
len(fftx),
endpoint=True,
base=2,
dtype=None,
)
- 1
)
fftx_bin_indices = np.round(
((fftx_bin_indices - np.max(fftx_bin_indices)) * -1) / (len(fftx) / BINS),
0
).astype(int)
fftx_bin_indices = np.minimum(
np.arange(len(fftx_bin_indices)),
fftx_bin_indices - np.min(fftx_bin_indices)
)
lfrequency_bin_energies = np.zeros(BINS)
rfrequency_bin_energies = np.zeros(BINS)
fftx_indices_per_bin = []
for bin_index in range(BINS):
bin_frequency_indices = np.where(fftx_bin_indices == bin_index)
fftx_indices_per_bin.append(bin_frequency_indices)
fftx_frequencies_this_bin = fftx[bin_frequency_indices]
try:
lfourier = np.abs(np.fft.rfft(lslot)[1:])
rfourier = np.abs(np.fft.rfft(rslot)[1:])
except:
lfourier = np.fft.fft(lslot)
rfourier = np.fft.fft(rslot)
ll, rl = np.split(np.abs(lfourier), 2)
lr, rr = np.split(np.abs(rfourier), 2)
lfourier = np.add(ll, lr[::-1])
rfourier = np.add(lr, rr[::-1])
power_normalization_coefficients = np.logspace(
np.log2(1),
np.log2(np.log2(rate / 2)),
len(fftx),
endpoint=True,
base=2,
dtype=None,
)
lfft = lfourier * power_normalization_coefficients
rfft = rfourier * power_normalization_coefficients
for bin_index in range(BINS):
lfrequency_bin_energies[bin_index] = np.mean(
lfft[fftx_indices_per_bin[bin_index]]
)
rfrequency_bin_energies[bin_index] = np.mean(
rfft[fftx_indices_per_bin[bin_index]]
)
return np.concatenate(
(
np.flip(lfrequency_bin_energies / 10000),
rfrequency_bin_energies / 10000
)
)
It does some normalization of the sample window. I’ve shamelessly stolen some code from here. Left and right channel are concatenated symmetrically.
Now some gradient for the synthwave effect.
def create_linear_gradient(frame):
"background gradient"
ratio = frame / FRAMES
g = cairo.LinearGradient(0, 0, 0, HEIGHT)
g.add_color_stop_rgba(0.00 + ratio*0.30, 0.000, 0.000, 0.000, 1)
g.add_color_stop_rgba(0.20 + ratio*0.30, 0.067, 0.000, 0.192, 1)
g.add_color_stop_rgba(0.75, 0.894, 0.141, 0.478, 1)
return g
def radial_gradient():
"gradient for lines at the bottom"
g = cairo.RadialGradient(
WIDTH / 2, HEIGHT / 2, 0, WIDTH / 2, HEIGHT / 2, 700
)
g.add_color_stop_rgba(0.0, 1.000, 0.360, 0.776, 1)
g.add_color_stop_rgba(1.0, 0.320, 0.020, 0.270, 1)
return g
def get_bars_gradient():
g = cairo.LinearGradient(0, 0, 0, HEIGHT)
g.add_color_stop_rgba(0.6, 0.095, 0.002, 0.205, 1)
g.add_color_stop_rgba(0.65, 0.411, 0.029, 0.347, 1)
return g
The background is simply a gradient.
def draw_bg(g):
"draw background"
c.set_source(g)
c.paint()
On the lower part, we don’t want any distraction, so this hack overpaints it.
def draw_bottom_bg():
"background of lines on the bottom"
g = cairo.LinearGradient(0, 0, 0, HEIGHT)
g.add_color_stop_rgba(0.8, 0.067, 0.000, 0.192, 1)
g.add_color_stop_rgba(1.0, 0.000, 0.000, 0.000, 1)
c.set_source(g)
c.rectangle(0, HEIGHT / 1.5, WIDTH, HEIGHT)
c.fill()
Now the fun part—the Sun.
def draw_sun(frame, g):
"sun with stripes, able to move in y axis"
SUN_SIZE = 300
OUTER_N = 5
SUN_SPEED = 0.085
STRIPES = 5
# outer glow
c.set_source_rgba(0.831, 0.024, 0.306, 1 / OUTER_N)
for i in range(OUTER_N):
c.arc(
WIDTH / 2,
HEIGHT / 2 + frame * SUN_SPEED,
SUN_SIZE + 6 * i,
0,
math.pi * 2
)
c.fill()
# sun
sg = cairo.LinearGradient(
0,
HEIGHT / 2 - SUN_SIZE / 2,
0,
HEIGHT / 2 + SUN_SIZE / 2
)
sg.add_color_stop_rgba(0.0, 0.992, 0.867, 0.533, 1)
sg.add_color_stop_rgba(0.4, 0.922, 0.125, 0.478, 1)
c.set_source(sg)
c.arc(
WIDTH / 2,
HEIGHT / 2 + frame * SUN_SPEED,
SUN_SIZE,
0,
math.pi * 2
)
c.fill()
# stripes not moving
c.set_source(g)
for i in range(STRIPES):
c.rectangle(
0,
HEIGHT / 2 - SUN_SIZE / 3 + (1.8 ** i) * 20,
WIDTH,
(i + 1) * 10
)
c.fill()
First the glow, then the sun as a gradient (not to be dull), then remove the stripes to achieve the sunset effect.
The stars are a bit tricky as they need to spread from the centre, but not to be behind the pierced Sun.
def draw_stars():
"draw stars on background"
i = 0
while i < len(STARS):
star = STARS[i]
c.set_source_rgba(1, 1, 1, star[3])
q1 = star[4] in [1, 2] and 1 or -1
q2 = star[4] in [2, 3] and 1 or -1
if star[4] in [1, 4]:
if math.sqrt(star[1]**2 + star[0]**2) > 300:
c.arc(
WIDTH / 2 + q1 * star[0],
HEIGHT / 2 + q2 * star[1],
star[2],
0,
math.pi * 2,
)
c.fill()
star[0] *= SPEED
star[1] *= SPEED
star[2] += 0.001
star[3] += 0.001
if star[0] > WIDTH / 2:
del STARS[i]
elif star[1] > HEIGHT / 2:
del STARS[i]
else:
i += 1
Stars are updated: new are generated every frame randomly.
def update_stars():
while len(STARS) < STAR_N:
STARS.insert(
0,
[
random.randint(0, WIDTH),
random.randint(0, HEIGHT),
random.randint(1, 2),
random.random(),
random.randint(1, 4),
],
)
We start with a set of horizontal lines and we move them slowly
(with SPEED
) down.
def draw_horizontal_lines():
if lines[1] > VERTICAL_GAP * DENSITY:
lines.insert(1, VERTICAL_GAP)
i = 0
c.set_source(lines_gradient)
while i < len(lines):
line = lines[i]
c.rectangle(0, HEIGHT / 1.5 + line, WIDTH, 2)
c.fill()
lines[i] *= SPEED
if lines[i] > HEIGHT / 2:
del lines[i]
else:
i += 1
The vertical lines are static, they just need to simulate the 3D effect.
def draw_vertical_lines():
for i in range(HLINES_N):
j = HLINES_N // 2 - i
c.move_to(
WIDTH / 2 + j * (WIDTH / HLINES_N) + (1.2 ** abs(j)),
HEIGHT / 1.5 + 2
)
c.rel_line_to(1.5, 0)
c.rel_line_to(
BOTTOM_LINE_WIDTH * j * (WIDTH / HLINES_N) * (1.035 ** i),
HEIGHT
)
c.rel_line_to(-BOTTOM_LINE_WIDTH * (1.035 ** i), 0)
c.close_path()
c.fill()
Now the spectrum bins! We just need to cut out the proper sample synchronized with the wav file, run the FFT and draw them properly.
def draw_spectrum_bins(frame):
leftslot = leftch[
(frame - (frame % 2)) * spf : (frame + 1 - (frame % 2)) * spf
]
rightslot = rightch[
(frame - (frame % 2)) * spf : (frame + 1 - (frame % 2)) * spf
]
current_bins = get_fft_bins(leftslot, rightslot, spf, sample_rate)
c.set_source(bars_gradient)
for i, bin_ in enumerate(current_bins):
w = 1.1 ** abs((i - BINS) / 2)
c.rectangle(
i * WIDTH / (BINS * 2) - 5,
HEIGHT / 1.5 - (bin_*w) + 3,
WIDTH / (BINS * 2) + 10,
bin_ * w,
)
c.fill()
And now the boring part, definitions to start with.
if __name__ == "__main__":
# definitions
FPS = 30
WIDTH = 1920
HEIGHT = 1080
FRAMES = 5800
STAR_N = 100
HLINES_N = 60
SPEED = 1.007
SUN_SPEED = 0.05
PRUH_RGB = (200 / 255, 100 / 255, 220 / 255)
WAVFILE = "betatron.wav"
OUTFILE = "sw.mp4"
BINS = 50
BOTTOM_LINE_WIDTH = 3
VERTICAL_GAP = 3
DENSITY = 1.17
STARS = [
[
random.randint(0, WIDTH / 2),
random.randint(0, HEIGHT / 2),
random.randint(1, 2), # radius
random.random(), # opacity
random.randint(1, 4), # quadrant
]
for _ in range(STAR_N)
]
# setup cairo and ffmpeg
surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, WIDTH, HEIGHT)
c = cairo.Context(surface)
ff = utils.Video(WIDTH, HEIGHT, FPS, OUTFILE, WAVFILE)
# setup audio analysis
sample_rate, wavdata = wavfile.read(WAVFILE)
leftch = wavdata.T[0]
rightch = wavdata.T[1]
spf = int(sample_rate / FPS)
lines = [0.0, 3.32, 3.94, 4.67, 5.54, 6.57, 7.79, 9.24, 10.96, 13.00,
15.42, 18.28, 21.68, 25.72, 30.50, 36.17, 42.90, 50.88, 60.34, 71.56,
84.87, 100.65, 119.36, 141.56, 167.88, 199.10, 236.12, 280.03, 332.10,
393.85, 467.09]
lines_gradient = radial_gradient()
bars_gradient = get_bars_gradient()
And the final loop for the whole video is this:
for frame in range(FRAMES):
print(f" {frame}", file=sys.stderr, end="\r")
bg_gradient = create_linear_gradient(frame)
draw_bg(bg_gradient)
draw_sun(frame, bg_gradient)
draw_stars()
update_stars()
draw_bottom_bg()
draw_horizontal_lines()
draw_vertical_lines()
draw_spectrum_bins(frame)
ff.write_frame(surface.get_data().tobytes())
ff.finish()
Simple, isn’t it?
last modified: 2023-02-08