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.
- The monitor attributes,
manufacturerthroughmonitor.liken.sh/id, are present on an HDMI or DisplayPort output whose monitor answers, and absent otherwise. They come from the ELD (EDID-Like Data), the block the graphics driver writes into the audio driver when a monitor answers. connectionTypeisanalogon the jack andbluetoothon a speaker. On an HDMI or DisplayPort output it comes from the ELD block, so an output with no monitor publishes no connection type.sinkNameis present while PipeWire holds a sink for the device, and left out when the name passes the API’s 64-character limit on a string attribute.codecis present only while the speaker is connected, because a codec is a property of a live transport, not of a pairing.codecsis present under the same condition ascodec, and absent when the device answers no choice. It holds whole names only, so a selector reads it with.contains().
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