SOUND — audio playback and mixing

Overview

sound is Lilush's audio module, aimed at game-engine use alongside term.gfx and the LEV runtime.

Why it talks to the kernel directly

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:

  1. 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.

  2. All mixing happens in-process. That is exactly what sound.mixer is for — there is no system mixer to fall back on.

Architecture

ModuleResponsibility
sound.coreC: raw PCM playback device, a float32 buffer, and the mixer hot path
sound.mixerLua: software mixer — simultaneous voices, gain/pan, looping
sound.wavLua: WAV (RIFF) decoder → buffer
sound.synthLua: oscillator + ADSR tone generator
sound.speechLua: 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.

Quick start

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, err

Opens /dev/snd/pcmC<card>D<device>p, negotiates hardware/software parameters, and prepares the stream. opts (all optional):

keydefaultmeaning
card0ALSA card number
device0PCM device number
rate48000sample rate (Hz)
channels2channel count
period1024frames per period (latency granularity)
periods4periods 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]) → buffer

A block of interleaved float32 samples. 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.

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) → mixer

opts.channels (default 2) must match the output buffer / device channels.

sound.run(dev, mx) calls fill for you on a writer task; call fill directly only if you drive the device yourself.

sound.wav

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.synth

sound.speech

Built-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.

Testing

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