-- voltie_dev

love2d for sound synthesis is actually quite fun

with the release of games such as Balatro, love2d once again proved its capabilities and gained some attraction. previously, i had come across people who looked down on the framework, calling it a toy and stuff. games like this and the community never fails to deliver though. still, i think there is an important area of use most people don't realize love2d is pretty convenient to use for, and that is sound synthesis.

the main approach to create sound with code in love2d is building up the samples, and to be able to actually play those, utilizing a queueable source with love.audio.newQueueableSource().

a basic example

in our main.lua let's define a source at the top of the file and create and assign a queueable source at load. to do this we should also define a sample rate (which is usually 44.1kHz or 48kHz), the bit-depth and if it is to be mono or stereo:

local source
local sample_rate = 44100 -- this is 44.1kHz
local bit_depth = 16
local channels = 1 -- for mono, 2 for stereo

function love.load()
    source = love.audio.newQueueableSource(sample_rate, bit_depth, channels)
end

alright. now we have a source that's ready to be populated with samples. in order to do so, we need a buffer created with love.sound.newSoundData(). we also need to choose a sensible size for the buffer to prevent underruns or taking too much memory. in this example this size will be 1024 samples.

local source, buffer
local sample_rate = 44100
local buffer_size = 1024 -- samples
local bit_depth = 16
local channels = 1

function love.load()
    source = love.audio.newQueueableSource(sample_rate, bit_depth, channels)
    buffer = love.sound.newSoundData(buffer_size, sample_rate, bit_depth, channels)
end

the main loop of the program will consist of filling the buffer with samples, queueing the source with the buffer, and playing the source. in this example, we will play a square wave. we need to define the variables below to keep track of the phase and the frequency:

local frequency = 440 -- A4
local phase = 0.0 -- this will range between 0.0 and 1.0 to determine the wave shape

in love.update() we'll use source:getFreeBufferCount() to fill the source's buffer.

function love.update(dt)
    while source:getFreeBufferCount() > 0 do -- runs until the source is fully queued
        for i = 0, buffer_size - 1 do -- SoundData samples are 0-indexed
            -- to generate a square wave at full amplitude
            -- we will set the sample to 1 up until phase = 0.5
            -- and set it to -1 after that to 1.0
            -- therefore half of the time we have 1 and the other half is -1
            local sample
            if phase < 0.5 then sample = 1
            else sample = -1 end
            buffer:setSample(i, 1, sample)

            -- now we need to update the phase according to
            -- delta_f = frequency / sample_rate
            phase = phase + frequency / sample_rate
            -- and we need to clamp it to 0.0-1.0 range
            if phase >= 1.0 then phase = phase - 1.0 end
        end
        -- we finally have the buffer filled, so we queue it
        source:queue(buffer)
    end

    -- we need to constantly call source:play()
    -- to make sure it doesn't stop if we underrun
    source:play()
end

putting it all together, it plays a nice crisp square wave at full amplitude:

local source, buffer
local sample_rate = 44100
local buffer_size = 1024
local bit_depth = 16
local channels = 1

local frequency = 440
local phase = 0.0

function love.load()
    source = love.audio.newQueueableSource(sample_rate, bit_depth, channels)
    buffer = love.sound.newSoundData(buffer_size, sample_rate, bit_depth, channels)
end

function love.update()
    while source:getFreeBufferCount() > 0 do
        for i = 0, buffer_size - 1 do
            local sample
            if phase < 0.5 then sample = 1
            else sample = -1 end
            buffer:setSample(i, 1, sample)

            phase = phase + frequency / sample_rate
            if phase >= 1.0 then phase = phase - 1.0 end
        end
        source:queue(buffer)
    end
    source:play()
end

although this is not very exciting, it's definitely a nice place to start. being able to calculate the samples as you wish gives you a lot of control and opens up many possibilities! you can check out my sid chip emulator library lovesid to see what's possible, and examine the code. it's basically this method but glorified.

time for more sophisticated stuff

we are going to build some new features on top of the previous example to demonstrate some cool ideas.

changing the duty cycle

the duty cycle is the percentage of period the wave spends in the high state. in our current square wave, this is 50%. by changing this number we can get different harmonics! it's trivial to adjust this value:

changing the 0.5 in line if phase < 0.5 then sample = 1 is directly our duty cycle. if we change it to, say 0.2, the wave is 1.0 20% of the time, and -1 80% of the time. this results in a sharper sound. you can even use a variable here and modulate it with time to get a more dynamic sound.

arpeggiator

to get a nice arpeggiated chord, we will define a table of frequencies:

-- frequencies for a C major chord
local freq = {
    261.63, -- C4
    329.63, -- E4
    392.00  -- G4
}

we will also keep track of the current note we're playing, and a timer to change it periodically:

local arp_current_idx = 1
local arp_timer = 0 -- this is not in seconds, rather in samples!!!
local arp_delta = 0.1 * sample_rate -- change note every 0.1s

function love.update(dt)
    while source:getFreeBufferCount() > 0 do
        for i = 0, buffer_size - 1 do
            arp_timer = arp_timer + 1 -- increment for each sample
            if arp_timer >= arp_delta then
                arp_timer = 0 -- reset the timer if we hit the limit

                arp_current_idx = arp_current_idx + 1 -- increment the note
                if arp_current_idx > #freq then arp_current_idx = 1 end
            end
        end
    end
end

if we put it together:

local source, buffer
local sample_rate = 44100
local buffer_size = 1024
local bit_depth = 16
local channels = 1

local freq = {
    261.63, -- C4
    329.63, -- E4
    392.00  -- G4
}
local arp_current_idx = 1
local arp_timer = 0
local arp_delta = 0.1 * sample_rate
local phase = 0.0

function love.load()
    source = love.audio.newQueueableSource(sample_rate, bit_depth, channels)
    buffer = love.sound.newSoundData(buffer_size, sample_rate, bit_depth, channels)
end

function love.update(dt)
    while source:getFreeBufferCount() > 0 do
        for i = 0, buffer_size - 1 do
            arp_timer = arp_timer + 1
            if arp_timer >= arp_delta then
                arp_timer = 0

                arp_current_idx = arp_current_idx + 1
                if arp_current_idx > #freq then arp_current_idx = 1 end
            end

            local sample
            if phase < 0.5 then sample = 1
            else sample = -1 end
            buffer:setSample(i, 1, sample)

            phase = phase + freq[arp_current_idx] / sample_rate
            if phase >= 1.0 then phase = phase - 1.0 end
        end
        source:queue(buffer)
    end
    source:play()
end

so what

these examples show only a small portion of what you can achieve with love2d in terms of synthesis. i'll probably write a new post sometime about more advanced approaches and stuff, but in the meantime, go wild. try to make a synth that takes in MIDI input, create an effect chain with reverb and distortion, try to make a visualizer that reacts to drums... it's only limited by your imagination.