A playback stream on a PulseAudio server: pipewire-pulse on a desktop, or PulseAudio itself.
On a desktop the sound server holds the card. A program that opens the
card finds it busy, or seizes it from every other program. This device
plays through the server instead, and speaks the server's native protocol
on its socket itself (sound.pulse_core). It answers the kernel PCM
device's methods, so sound.run and every writer above it keep one shape.
The server asks for bytes, and the device sends no more than it asked. So
a writer waits for a request, not for a writable descriptor: the device
adds wait, and sound.run calls it. A wrapper between sound.run and
this device must pass wait on. Without it run waits writable on a
socket that is always writable, and spins while write answers EAGAIN.
| Name | Signature |
|---|---|
| server | server(getenv) -> path, err |
| cookie | cookie(getenv, read) -> cookie |
| open | open(opts) -> dev, err |
server(
getenv) ->path,err
Find the server's socket
getenv reads a variable, os.getenv by default. $PULSE_SERVER comes
first: its first entry that names a Unix socket, as unix:/path or as a
bare absolute path. Steam's container sets it to the host's socket, bound
inside. A {machine-id} prefix is dropped, since a socket path is local
to the machine anyway. With $PULSE_SERVER unset, the socket is
$XDG_RUNTIME_DIR/pulse/native.
Returns nil and an error when $PULSE_SERVER names no Unix socket, or
when neither variable is set. TCP is not taken.
cookie(
getenv,read) ->cookie
Read the client's cookie
getenv reads a variable, os.getenv by default; read reads a file,
std.fs.read_file by default. The first of $PULSE_COOKIE,
pulse/cookie under $XDG_CONFIG_HOME (else $HOME/.config), and
$HOME/.pulse-cookie that holds 256 bytes, libpulse's order. Returns nil
when none does. The device then sends zeros: a server takes a client of
its own uid by the socket's credentials, and pipewire-pulse never reads
the cookie.
open(
opts) ->dev,err
Open a playback stream on the sound server
sound.open calls this for server = "pulse". The handshake blocks, up
to five seconds a step, since a device opens before lev.run.
| opts field | default | description |
|---|---|---|
socket | from server() | the server's socket path |
name | "lilgame" | the client and stream name a mixer shows |
rate | 48000 | sample rate, Hz; the server converts |
channels | 2 | 1 or 2 |
period | 1024 | frames per period: what wait waits for |
periods | 4 | periods in the ring the server keeps |
connect | pulse_core.connect | the suite's seam: takes the connect table |
The ring is the whole latency: the server splits it between its sink and
the stream's queue. prebuf is the server's default, about the ring, so
playback starts once the ring is nearly full, as on a card. A server that
grants a ring under a period is refused, with the error naming both:
write could send no more than the ring, and sound.run would count each
cut period as played.
The device answers the PCM device's methods with these differences:
| method | description |
|---|---|
dev:fd() | the socket; wait is what to wait on |
dev:format() | always "f32"; nothing is clamped |
dev:avail() | frames the server asked for and has not had |
dev:write(buf) | sends up to avail() frames; nil and "EAGAIN" at none; nil and "EPIPE" after the server's underflow, until recover |
dev:recover() | clears the underflow; the server buffers again by itself |
dev:start() | playback starts before the ring fills |
dev:drop() | the server drops what it holds |
dev:close() | ends the stream and the connection |
dev:wait(t) | yields to LEV until a period is asked for, an underflow waits, or the stream fails: true, or nil and "timeout" after t seconds, or nil and the error |
dev:info() | the protocol versions, the sink, the granted ring in bytes, sink_latency in seconds, and the counts of underflows, overflows, starts and refused commands |
A lost server answers nil and "connection lost", never "EPIPE", so
sound.run ends rather than recover forever.