sound.pulse

index ยท sound

Overview

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.

Functions

NameSignature
serverserver(getenv) -> path, err
cookiecookie(getenv, read) -> cookie
openopen(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 fielddefaultdescription
socketfrom server()the server's socket path
name"lilgame"the client and stream name a mixer shows
rate48000sample rate, Hz; the server converts
channels21 or 2
period1024frames per period: what wait waits for
periods4periods in the ring the server keeps
connectpulse_core.connectthe 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:

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