Betatron

Watch on YouTube

A second synthwave track

My second synthwave track made entirely using free software:

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?

first published: 2022-10-30
last modified: 2023-02-08