Devices

audio-operator publishes one Dynamic Resource Allocation (DRA) device for each playback PCM device on the sound card its pod claims from liken: each HDMI or DisplayPort output, and the analog jack. Membership follows the card’s playback PCM devices, whether or not a cable is plugged into each one. An output whose monitor is unplugged stays in the slice, with taints, so a claim on it parks instead of failing.

On a machine whose pod also claimed a Bluetooth media bus from the bluetooth.liken.sh driver, the operator publishes one more device for each paired Bluetooth speaker. Membership there is the paired set: a speaker that is switched off stays in the slice with taints, and a speaker leaves it only when somebody unpairs it.

To a consumer, a speaker and an HDMI output are the same kind of device. Both publish under audio.liken.sh, both select the same way, and both deliver the same socket and a node name.

The operator publishes only A2DP sinks. HFP and HSP, the headset profiles that add a microphone, would need a socket in the host’s network namespace, and this pod has no host network.

The operator publishes the devices into one ResourceSlice per node, named <node>-audio.liken.sh, beside the slice liken itself publishes:

kubectl get resourceslice <node>-audio.liken.sh -o yaml
spec:
  driver: audio.liken.sh
  nodeName: kitchen
  devices:
    - name: a0-ab-51-33-b7-12
      attributes:
        output: {string: a0-ab-51-33-b7-12}
        address: {string: "A0:AB:51:33:B7:12"}
        name: {string: Kitchen Speaker}
        connectionType: {string: bluetooth}
        connected: {bool: true}
        codec: {string: sbc}
        codecs: {string: "sbc aptx sbc_xq aptx_ll"}
        sinkName: {string: bluez_output.A0_AB_51_33_B7_12.1}
    - name: card0-pcm3
      attributes:
        output: {string: card0-pcm3}
        card: {int: 0}
        pcm: {int: 3}
        connectionType: {string: hdmi}
        manufacturer: {string: GSM}
        product: {string: "5b09"}
        monitorName: {string: LG ULTRAWIDE}
        lpcmChannels: {int: 2}
        lpcmMaxRateHz: {int: 48000}
        lpcmBitDepths: {string: "16 20 24"}
        speakers: {string: FL/FR}
        sinkName: {string: liken.audio.card0-pcm3}
        monitor.liken.sh/id: {string: gsm-5b09-lg-ultrawide}
    - name: card0-pcm7
      attributes:
        output: {string: card0-pcm7}
        card: {int: 0}
        pcm: {int: 7}
        sinkName: {string: liken.audio.card0-pcm7}
      taints:
        - key: audio.liken.sh/disconnected
          effect: NoExecute
        - key: audio.liken.sh/no-monitor
          effect: NoSchedule

The device class

A consumer claims through a DeviceClass that selects every device this driver publishes:

apiVersion: resource.k8s.io/v1
kind: DeviceClass
metadata:
  name: audio-output
spec:
  selectors:
    - cel:
        expression: device.driver == "audio.liken.sh"

You create this class, because a class a workload claims through is cluster policy, and this manual calls it audio-output throughout; it is yours to rename or narrow, the way a StorageClass is. The class alone allocates any output on the card. To name one, add a selector on the attributes below, as Play sound to an output shows.

The base ships one class of its own, sound-card, and that one is not for consumers: the operator’s own claim template names it, and its pod claims every sound device on its node through it, from the raw devices liken’s driver publishes. Devices in the liken manual describes those raw devices.

The attributes

The device name is the ALSA card and PCM number, card0-pcm3. The number comes from the codec’s pin order, which the driver enumerates the same way at every boot on the same hardware and kernel. It is not stable across machines, and a claim that must survive a kernel change selects on the monitor attributes instead. The name repeats as the output attribute because a CEL selector reads attributes and never the device’s name.

A Bluetooth speaker’s name is its peer MAC address in lowercase with dashes, a0-ab-51-33-b7-12, because a DRA device name must be a DNS label and a colon is not legal in one. The MAC is the one identity BlueZ carries that survives a reboot.

Attribute Type What it is
output string the device’s name: card0-pcm3 or a0-ab-51-33-b7-12
connectionType string hdmi, displayport, analog, or bluetooth
sinkName string the PipeWire node name a consumer’s streams target
card int the ALSA card number
pcm int the PCM device number on that card
manufacturer string the monitor’s three-letter PNP id, from the ELD: GSM is LG
product string the monitor’s product code, four lowercase hexadecimal digits
monitorName string the monitor’s name, the same EDID descriptor the display operator publishes as model
lpcmChannels int the highest uncompressed channel count the monitor accepts
lpcmMaxRateHz int the highest uncompressed sample rate the monitor accepts, in hertz
lpcmBitDepths string the uncompressed depths the monitor accepts, ascending: 16 20 24
speakers string the speaker allocation, in the kernel’s names: FL/FR
monitor.liken.sh/id string the pairing identity, described below
address string the speaker’s peer MAC, uppercase with colons: A0:AB:51:33:B7:12
name string the speaker’s name, the alias BlueZ reports
connected bool whether bluetoothd has the speaker connected right now
codec string the A2DP codec the transport negotiated: sbc, aptx, ldac
codecs string every codec the speaker and this image both support, space separated, the one playing first, in the same spelling as codec

A selector reads an unqualified attribute through the driver’s domain: device.attributes["audio.liken.sh"].output. The pairing attribute is the one exception; it reads as the key id under the domain monitor.liken.sh.

Only output is always present. The rest divide by the kind of device that carries them: card, pcm, and the monitor attributes are an ALSA output’s, and address, name, connected, and codec are a speaker’s.

A selector on the list asks for a name inside a string:

has(device.attributes["audio.liken.sh"].codecs) &&
device.attributes["audio.liken.sh"].codecs.contains("aptx")

A selector that reads a missing attribute fails the whole allocation, so guard every attribute in the bullets above:

has(device.attributes["monitor.liken.sh"].id) &&
device.attributes["monitor.liken.sh"].id == "gsm-5b09-lg-ultrawide"

The pairing identity

monitor.liken.sh/id pairs a monitor’s speakers with that monitor’s screen, which the display operator publishes from the same monitor’s EDID. Both drivers build the value the same way, byte for byte, because the scheduler compares them under a matchAttribute constraint. The value is the lowercase PNP id, the four-digit hexadecimal product code, then the lowercase monitor name with each run of spaces turned to one dash. An LG UltraWide reads gsm-5b09-lg-ultrawide. A monitor with no name keeps the two-part form, boe-095f. The name is optional because one driver can read a name the other cannot. If a missing name dropped the whole value, one driver would publish the attribute and the other none, and a constraint across the two would park forever.

The attribute has its own domain because an unqualified name belongs to the driver that published it. A bare monitorName here and a bare model in the display driver’s slice would never match.

The ELD has no serial number, so the identity names a model, not a unit. Two monitors of one model publish one value, and a constraint is satisfied by either pairing.

The taints

An output that cannot play is tainted, never deleted. Deleting it would strand the claim that names it: the kubelet retries its prepare call against a device in no slice, with no bound. A device leaves the slice only when the card does.

A speaker leaves the slice only when somebody unpairs it.

Key Effect When it appears Who tolerates it
audio.liken.sh/disconnected NoExecute the output cannot play now the consumer, with its own tolerationSeconds
audio.liken.sh/no-monitor NoSchedule no monitor answers on this HDMI or DisplayPort output nobody
audio.liken.sh/no-sink NoSchedule PipeWire holds no node for this device nobody

A paired speaker that is switched off carries the disconnected and no-sink taints, which is what lets a consumer claim it before it exists to play into. The pod parks Unschedulable, somebody switches the speaker on, WirePlumber builds the node, the operator drops the taints, and the pod starts.

The NoExecute taint ends the holder’s pod after the claim’s tolerationSeconds, so a consumer tolerates it to survive a short drop. A tolerated NoExecute taint still permits allocation, so one of the untolerated NoSchedule taints is always present with it, and that one holds a new pod Unschedulable until the output can play. The two reasons have separate keys because they have separate repairs: no-monitor clears when the cable returns, and no-sink clears only when the pod is replaced and PipeWire declares its nodes again.

The analog jack has none of these while its sink is up. Most codecs report nothing about the socket, and no signal can prove that sound reaches anyone, so the operator publishes the port it reads and a person who wired something claims it.

What a prepared claim delivers

The delivery is a mount and two environment variables, applied to the container by the runtime. There is no device node, because a consumer does not open a PCM device: it connects to PipeWire, which holds every PCM device on the card.

What Value
mount /var/run/audio.liken.sh, read-only, the directory that holds PipeWire’s socket
PIPEWIRE_REMOTE /var/run/audio.liken.sh/pipewire-0
PIPEWIRE_NODE the allocated device’s sink name

A Bluetooth speaker delivers the same three things. One PipeWire holds the card’s sinks and the radio’s, so a pod that claims an HDMI output and a speaker at once receives one socket and two node names.

PipeWire’s own client library reads both variables. A PIPEWIRE_REMOTE that starts with a slash is used as an absolute socket path, and PIPEWIRE_NODE sets target.object on every stream. The mount is read-only because connecting to a Unix socket needs write permission on the socket, not on the directory.

Each variable holds one value, so two allocations delivered to one container overwrite, and the last wins. One container holds at most one output; a pod that plays into two outputs runs two containers.

The sinks PipeWire holds

The sink a consumer targets is one this operator declared. WirePlumber’s ALSA monitor enumerates cards through libudev, and a liken machine runs no udevd, so the monitor would build nothing. Instead, the pod’s declare init container enumerates the card’s playback PCM devices through the ALSA control interface and writes one sink declaration for each into a PipeWire configuration drop-in, before the daemons start. The sink name is derived from the ALSA address alone, liken.audio. plus the device name, so it is the same at every start on the same card:

liken.audio.card0-pcm3

Every PCM device is declared, monitor or not. PipeWire reads the declarations once, so the set is fixed while the daemon runs. A set that followed the cables would need a restart every time somebody moved one, and a card’s PCM devices are fixed when its driver binds. A PCM device that appears or leaves after that publishes with the no-sink taint until the pod is replaced.

This path gives some things up. The card-profile machinery needs the ALSA monitor, so there is no hardware mixer volume and no profile switching: volume is PipeWire’s software volume, and the channel layout comes from what is connected when PipeWire starts.

The Bluetooth sinks

A Bluetooth sink is built the other way round from an ALSA sink: WirePlumber’s bluez monitor is its source, because a Bluetooth speaker creates nothing in the kernel. The audio exists only while a sound server holds bluetoothd’s D-Bus socket, registers a media endpoint, negotiates the codec, encodes the samples, and writes to the L2CAP socket bluetoothd passes it as a file descriptor.

The pod reaches that socket through its claim. The bluetooth.liken.sh driver publishes its media bus as a device, this operator’s class selects every device that stamps sound.liken.sh/supportsSound, and the claim’s allocationMode of All takes the sound card and the media bus together. The delivery is a read-only mount of the bus socket’s directory and DBUS_SYSTEM_BUS_ADDRESS. The pod’s declare init container reads that variable, and writes the WirePlumber fragment that turns the bluez monitor on only when the variable is set.

WirePlumber names the node from bluez_output, the peer MAC with underscores, and an object id, so the name can change when the speaker reconnects. The operator republishes the name and rewrites every prepared claim’s file from the same graph read, the same way it does for a sink that a profile change renamed.

bluez_output.A0_AB_51_33_B7_12.1

A2DP is advertised on the radio exactly while this pod holds the media bus, because BlueZ advertises the profile only when an endpoint is registered. Pairing a speaker therefore works only while this pod runs and holds the bus.

Choosing the codec

WirePlumber picks the codec when a speaker connects. The codecs attribute says what else the speaker offers, and a claim states the one it wants in an opaque config block, the channel DRA gives a driver for its own parameters:

apiVersion: resource.k8s.io/v1
kind: ResourceClaim
metadata:
  name: kitchen-speakers
  namespace: media
spec:
  devices:
    config:
      - opaque:
          driver: audio.liken.sh
          parameters:
            codec: sbc
    requests:
      - name: speaker
        exactly:
          deviceClassName: audio-output

codec is the only parameter this driver reads, and an unknown key fails the prepare, so a typo stops the pod instead of playing something nobody asked for. A block with no requests list applies to every request in the claim, and a requests list narrows it to the requests it names.

A DeviceClass can carry the same opaque block, which makes a codec cluster policy for every claim that allocates through the class. The scheduler resolves the class’s config and the claim’s into one list on the allocation and marks each entry’s source, and that list is what the driver reads. The claim’s own choice wins over the class’s, whichever order the two are listed in.

When the requested codec is not the one playing, the prepare call writes it on the speaker’s PipeWire device, waits for the rebuilt sink to report the new codec, and only then delivers PIPEWIRE_NODE. The wait is bounded at ten seconds, and the renegotiation itself takes one to four.

Three things refuse. A codec stated for an output that is not a Bluetooth speaker fails, because a sound card has no air codec. A codec the speaker does not offer fails, and the message names the offered list. A switch the graph never reports fails at the ten-second bound. Each failure holds the pod in ContainerCreating, and the claim’s events carry the message.

Releasing the claim renegotiates nothing. The choice stands until the next claim states one, or until the speaker reconnects, which hands the pick back to WirePlumber.

The sink’s volume

Every sink this pod builds is born at unity. The pod stores no volumes, and WirePlumber’s own default for an unstored sink is 40 percent, a desktop guard that would cost resolution on a machine that plays only what a claim delivers. Loudness belongs to the consumer’s stream volume and to the hardware behind the jack.

A prepare on a Bluetooth speaker also writes unity on every channel of the sink it delivers, switch or no switch. A speaker allocates to one claim at a time, so any level a prepare finds is a leftover from an earlier tenant or a hand-run tool, never the arriving consumer’s choice. The card’s own outputs take no such write.

The slice’s lifetime

The operator creates its slice on the first pass, rewrites it when the card or the graph disagrees with it, and never deletes it. The Node owns the slice, so a node that leaves the cluster takes the slice with it. The slice outlives the operator’s pod on purpose: prepared claims keep naming its devices across a restart. Removing the operator for good ends with:

kubectl delete resourceslice <node>-audio.liken.sh