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)
| Module | Description |
|---|---|
| sound.mixer | In-process software mixer. |
| sound.speech | Lightweight English text-to-speech. |
| sound.synth | Tiny synthesizer for chiptune-style game audio. |
| sound.wav | Minimal WAV (RIFF/WAVE) decoder -> float32 sample buffer. |
| Name | Signature |
|---|---|
| open | open(opts) -> dev, err |
| buffer | buffer(frames, channels) -> buf, err |
| from_pcm | from_pcm(data, channels, fmt) -> buf, err |
| resample | resample(buf, src_rate, dst_rate) -> resampled, err |
| run | run(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 field | default | description |
|---|---|---|
card | 0 | ALSA card number |
device | 0 | PCM device number |
rate | 48000 | sample rate, Hz |
channels | 2 | channel count |
period | 1024 | frames per period (latency granularity) |
periods | 4 | periods 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:
| Method | Description |
|---|---|
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:
| Method | Description |
|---|---|
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 field | default | description |
|---|---|---|
timeout | 0.1 | poll/cancel-check interval, seconds |
cancel | nil | a lev.cancel_token — when fired the task exits |
name | "sound_writer" | task name |
spawn | true | set 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().