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.
| Module | Description |
|---|---|
| sound.mixer | A software mixer. |
| sound.pulse | A playback stream on a PulseAudio server: pipewire-pulse on a desktop, or PulseAudio itself. |
| sound.speech | English text-to-speech: text in, a mono sample buffer out. |
| sound.synth | A tone synthesizer for game sounds. |
| sound.wav | A WAV (RIFF/WAVE) decoder. |
| 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 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 field | default | description |
|---|---|---|
server | none | "pulse" for the sound server; any other value is refused |
card | 0 | ALSA card number |
device | 0 | PCM device number |
rate | 48000 | sample rate, Hz |
channels | 2 | channel count |
period | 1024 | frames per period: the latency step |
periods | 4 | periods in the ring |
format | "auto" | "f32", "s16", or "auto": f32 when the card takes it, else s16; any other value is refused |
| method | description |
|---|---|
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.
| method | description |
|---|---|
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 field | default | description |
|---|---|---|
timeout | 0.1 | seconds between checks of cancel |
cancel | nil | a lev.cancel_token; the task ends when it fires |
name | "sound_writer" | task name |
spawn | true | false returns the task body as a function, for the caller to run |
stats | nil | a 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.