sound

index

Overview

Audio playback: a raw ALSA PCM device or a stream on a PulseAudio server, float32 sample buffers, a mixer, a WAV loader, a tone synth and text-to-speech.

This module wraps sound.core and re-exports the Lua layers: sound.mixer (the mixer's new), sound.wav, sound.synth, sound.speech and sound.pulse.

Submodules

ModuleDescription
sound.mixerA software mixer.
sound.pulseA playback stream on a PulseAudio server: pipewire-pulse on a desktop, or PulseAudio itself.
sound.speechEnglish text-to-speech: text in, a mono sample buffer out.
sound.synthA tone synthesizer for game sounds.
sound.wavA WAV (RIFF/WAVE) decoder.

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 playback device

With no server, opens /dev/snd/pcmC<card>D<device>p, sets the hardware and software parameters, and prepares the stream. Playback starts by itself once the ring fills. The device is held exclusively while open: no other program can play through it.

With server = "pulse", opens a stream on the desktop's sound server instead, through sound.pulse.open. That takes rate, channels, period and periods alike. Its own block lists its other fields and the methods that differ. The choice is the caller's. A socket that exists says nothing about who holds the card: a desktop's sound server starts on demand from its socket, even at a bare console.

opts fielddefaultdescription
servernone"pulse" for the sound server; any other value is refused
card0ALSA card number
device0PCM device number
rate48000sample rate, Hz
channels2channel count
period1024frames per period: the latency step
periods4periods in the ring
format"auto""f32", "s16", or "auto": f32 when the card takes it, else s16; any other value is refused
methoddescription
dev:fd()the PCM node's descriptor, for lev.core.wait_writable
dev:period()frames per period: what run writes at a time
dev:channels()the channel count the card took
dev:rate()the sample rate the card took
dev:format()"f32" or "s16": the format the card took
dev:avail()free frames in the ring; or nil and an error
dev:write(buf)frames written; nil and "EAGAIN" with the ring full; nil and "EPIPE" on an underrun
dev:recover()prepare the stream again after an underrun
dev:start()start playback before the ring is full
dev:drop()stop now and discard the queued frames
dev:close()release the card

write clamps samples to [-1, 1] and converts them to s16 when the card took s16.

buffer(frames, channels) -> buf, err

Create a sample buffer filled with silence

A buffer holds interleaved float32 samples in [-1, 1]. channels defaults to 2. Frame offsets count from 1.

methoddescription
b:frames(), b:channels()size
b:clear()reset to silence
b:get(frame, ch), b:set(frame, ch, v)read or write one sample
b:mix(src, dst_off, src_off, n, ...)add n frames of src, scaled by the gains after n
b:osc(wave, freq, rate, phase, amp, dst_off, n, seed)add an oscillator; returns the next phase. seed is noise's: with it the same bytes every call, without it the process-wide stream
b:fade(g0, g1, dst_off, n)multiply by a linear gain ramp
b:tostring()the raw interleaved f32 bytes

mix with no gains adds at unity, with one gain scales every channel, and with one gain per channel pans. A mono source mixed into a stereo buffer goes to both channels. mix does not clamp.

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

Decode raw PCM bytes into a sample buffer

data is interleaved little-endian PCM. fmt is "u8", "s16", "s24", "s32" or "f32". Returns nil and an error on failure.

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

Resample a buffer to a different sample rate

Linear interpolation. Returns a new buffer and leaves buf alone. Pass dev:rate() as dst_rate so an asset plays at its pitch. Returns nil and an error on failure.

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

Run the audio writer task on the LEV event loop

Spawns a task into the running lev.run. The task waits until the device takes a period, fills one from the mixer, and writes it. With no voices playing it writes silence, so the stream never runs dry. A device with a wait method, the pulse one, is waited on through it; any other is waited on writable through dev:fd().

opts fielddefaultdescription
timeout0.1seconds between checks of cancel
cancelnila lev.cancel_token; the task ends when it fires
name"sound_writer"task name
spawntruefalse returns the task body as a function, for the caller to run
statsnila table the task counts in: periods played, underruns recovered

The task never resets stats; the caller owns it. An underrun loses the period that met it. The task prepares the stream again and fills the next period fresh. So periods counts what played and underruns what did not. The task ends on a device error other than an underrun or a full ring.