sound is Lilush's audio module, aimed at game-engine use alongside
term.gfx and the LEV runtime.
Lilush links fully static against musl, and a static musl binary cannot
dlopen. ALSA's entire plugin/config layer — including dmix, its software
mixer, and the default device — is loaded through dlopen, so it is
unavailable to Lilush regardless of which audio library we link. Linking
libasound or vendoring a library like miniaudio would still be limited to
raw hw: device access and drag in a heavy dependency for no capability gain.
So sound talks to the kernel PCM interface directly: ioctl() on
/dev/snd/pcmC*D*p, using the structures and ioctl numbers from
<sound/asound.h> (shipped by the build container's linux-headers). Zero
third-party dependencies, static by construction, and the device fd plugs
straight into LEV's wait_writable.
Two consequences follow from there being no dmix:
The device is held exclusively. While sound has the device open, other
programs cannot play through it (and vice-versa). Stop other audio apps
first.
All mixing happens in-process. That is exactly what sound.mixer is for
— there is no system mixer to fall back on.
| Module | Responsibility |
|---|---|
sound.core | C: raw PCM playback device, a float32 buffer, and the mixer hot path |
sound.mixer | Lua: software mixer — simultaneous voices, gain/pan, looping |
sound.wav | Lua: WAV (RIFF) decoder → buffer |
sound.synth | Lua: oscillator + ADSR tone generator |
sound.speech | Lua: formant (Klatt-style) English text-to-speech — see SPEECH.md |
Every Lua-facing sample buffer is interleaved float32 in [-1, 1]. Mixing accumulates in float and never clips intermediate sums; the single clamp to [-1, 1] happens in the device on write, where float is also converted to the hardware format (s16) if the card does not accept float directly. Frame offsets at the Lua boundary are 1-indexed, matching the rest of the codebase.
local lev = require("lev")
local gfx = require("term.gfx")
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.08, wave = "square", amp = 0.4 }))
local music = assert(sound.wav.load("music.wav", { rate = dev:rate() }))
lev.run(function()
sound.run(dev, mx) -- dedicated audio writer task
mx:play(music, { gain = 0.6, loop = true })
gfx.loop({
fps = 60,
canvas = gfx.canvas({ width = 320, height = 200 }),
display = true,
render = function(c) --[[ draw ]] end,
input = function(key)
if key == " " then mx:play(blip, { pan = 0.3 }) end
end,
}):run()
end)
The frame loop drives visuals and just calls mx:play(); the sound.run
writer task does the device I/O. Both run under one lev.run. Audio is fed on
its own fd-driven schedule (not from update) because it must stay ahead of
the hardware more reliably than frames are rendered.
sound.open(opts) → device | nil, errOpens /dev/snd/pcmC<card>D<device>p, negotiates hardware/software parameters,
and prepares the stream. opts (all optional):
| key | default | meaning |
|---|---|---|
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 (~85 ms at default) |
format | "auto" | "auto" (float, else s16), "f32", or "s16" |
Device methods: dev:fd(), dev:period(), dev:channels(), dev:rate(),
dev:format() ("f32"/"s16"), dev:avail() (free frames), dev:write(buf)
(→ frames written, or nil,"EAGAIN"/nil,"EPIPE"/nil,err), dev:recover()
(re-prepare after an underrun), dev:start(), dev:drop(), dev:close().
Playback auto-starts once the ring buffer fills.
sound.buffer(frames [, channels=2]) → bufferA block of interleaved float32 samples. 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.
Other core functions: sound.from_pcm(data, channels, fmt) (decode raw PCM
bytes — "u8", "s16", "s24", "s32", "f32" — to a float buffer) and
sound.resample(buf, src_rate, dst_rate) (linear resample).
sound.mixer(opts) → mixeropts.channels (default 2) must match the output buffer / device channels.
mx:play(buf, opts) → voice. opts: gain (default 1), pan (-1..1,
equal-power), loop (bool). Voice: :stop(), :set_gain(g), :set_pan(p),
:is_active().
mx:fill(out, n) — clear out, mix every voice's next n frames into it,
advancing playheads and reaping finished non-looping voices.
mx:stop(id), mx:stop_all(), mx:active() (voice count).
sound.run(dev, mx) calls fill for you on a writer task; call fill directly
only if you drive the device yourself.
sound.wavsound.wav.decode(data, opts) → buffer, info | nil, err
sound.wav.load(path, opts) → buffer, info | nil, err
Supports uncompressed integer PCM (8/16/24/32-bit) and IEEE float32, including
WAVE_FORMAT_EXTENSIBLE. opts.rate resamples to that rate (pass dev:rate()
so assets play at the right pitch). info is { channels, rate, bits, frames }.
sound.synthsound.synth.tone(opts) → buffer | nil, err. opts: freq or note
(e.g. "A4", "C#5"); dur (s, default 0.25); rate (48000); channels
(2); wave ("sine"/"square"/"saw"/"triangle"/"noise"); amp
(0.5); ADSR attack/decay/sustain(level)/release.
sound.synth.note_freq(name) → Hz (12-TET, A4 = 440).
sound.speechBuilt-in English text-to-speech: a deterministic Klatt-style formant
synthesizer with voice presets and knobs (pitch, formant scale, rate,
breathiness, robotic quantize). Mono output, resampled to opts.rate:
local buf = assert(sound.speech.say("Docking request granted.", {
voice = "computer", rate = dev:rate(),
}))
mx:play(buf, { gain = 0.9 })
Full pipeline, phoneme-level API (phonemize/synth), knob semantics, and
the tuning workflow: SPEECH.md.
Unit tests (buffer math, PCM decode, resample, WAV parsing, synth, mixer) need no hardware and run under the normal suite:
./run_tests.bash sound
Actual playback needs a real device. A manual smoke test lives at
tests/sound/manual/smoke.lua — run it on a host with /dev/snd (and
membership in the audio group). It sits in a subdirectory so run_tests.bash
does not pick it up (the build container has no sound device):
lilush tests/sound/manual/smoke.lua