sound

index

Overview

Audio module for Lilush, aimed at game-engine use.

A static-musl Lilush cannot dlopen ALSA's plugin layer (dmix, the "default" device), so this module talks to the kernel PCM interface directly and mixes in-process.

It is a thin aggregator over sound.core, sound.mixer, sound.wav, sound.synth and sound.speech submodules.

Typical use, alongside a term.gfx frame loop, under one lev.run:

local sound = require("sound")
local dev = assert(sound.open({ rate = 48000, channels = 2 }))
local mx  = sound.mixer({ channels = dev:channels() })
local blip = assert(sound.synth.tone({ note = "A5", dur = 0.1, wave = "square" }))

lev.run(function()
    sound.run(dev, mx)            -- dedicated fd-driven writer task
    gfx.loop({ ... , input = function(k) if k == " " then mx:play(blip) end end }):run()
end)

Submodules

ModuleDescription
sound.mixerIn-process software mixer.
sound.speechLightweight English text-to-speech.
sound.synthTiny synthesizer for chiptune-style game audio.
sound.wavMinimal WAV (RIFF/WAVE) decoder -> float32 sample buffer.

Functions

NameSignature
openopen(opts) -> dev, err
bufferbuffer(frames, channels) -> buf, err
from_pcmfrom_pcm(data, channels, fmt) -> buf, err
resampleresample(buf, src_rate, dst_rate) -> resampled, err
runrun(dev, mx, opts) -> task/fn

open(opts) -> dev, err

Open a PCM playback device

Opens /dev/snd/pcmC<card>D<device>p, negotiates hardware/software parameters, and prepares the stream.

Playback auto-starts once the ring buffer fills. While the device is open it is held exclusively — there is no dmix, so other programs cannot play through it.

opts fielddefaultdescription
card0ALSA card number
device0PCM device number
rate48000sample rate, Hz
channels2channel count
period1024frames per period (latency granularity)
periods4periods in the ring buffer
format"auto""auto" picks float if the card accepts it, else s16; or force "f32"/"s16"

The device object has the following methods:

MethodDescription
dev:fd()TBD...
dev:period()
dev:channels()
dev:rate()
dev:format()
dev:avail()
dev:write(buf)
dev:recover()
dev:start()
dev:drop()
dev:close()

buffer(frames, channels) -> buf, err

Create a sample buffer filled with silence

Allocates a block of interleaved float32 samples in [-1, 1]; channels defaults to 2. Frame offsets at the Lua boundary are 1-indexed.

The buffer object has the following methods:

MethodDescription
b:frames() / b:channels()size
b:clear()reset to silence
b:get(frame, ch) / b:set(f, c, v)read/write one sample (1-indexed)
b:mix(src, dst_off, src_off, n, ...g)add n frames of src scaled by gains (mixer core)
b:osc(wave, freq, rate, phase, amp, dst_off, n)additive oscillator → next phase
b:fade(g0, g1, dst_off, n)linear gain ramp (envelope segment)
b:tostring()raw interleaved f32 bytes

mix gains: none → unity; one → master volume; per-channel → pan. A mono source mixed into a stereo destination is duplicated to both channels.

from_pcm(data, channels, fmt) -> buf, err

Decode raw PCM bytes into a sample buffer

Decodes raw interleaved little-endian PCM bytes into a float32 buffer. fmt is one of "u8", "s16", "s24", "s32", "f32".

Used by sound.wav after parsing the RIFF container.

Returns nil and an error string on failure.

resample(buf, src_rate, dst_rate) -> resampled, err

Resample a buffer to a different sample rate

Linear-interpolation resample, so an asset at one sample rate plays at the correct pitch on a device at another (pass dev:rate() as dst_rate).

Returns a new buffer; the source is not modified. Returns nil and an error string on failure.

run(dev, mx, opts) -> task/fn

Run the audio writer task on the LEV event loop

Spawns (into the current lev.run) a coroutine that waits for the device to be writable and feeds it one period of mixed audio at a time — the recommended way to drive audio next to a frame loop. When no voices play it writes silence to keep the stream from underrunning.

opts fielddefaultdescription
timeout0.1poll/cancel-check interval, seconds
cancelnila lev.cancel_token — when fired the task exits
name"sound_writer"task name
spawntrueset false to get the loop body back as a function and drive it yourself

Requires LEV; with spawn ~= false it must be called inside lev.run().