
# Devices

`audio-operator` publishes one
[Dynamic Resource Allocation (DRA)](https://kubernetes.io/docs/concepts/scheduling-eviction/dynamic-resource-allocation/)
device for each PCM device on the sound card its pod claims from
[`liken`](https://liken.sh/docs/): a sink for each HDMI or
DisplayPort output, the analog jack, and a USB card's playback
side, and a source for each capture side. Membership follows the
card's 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.

Every device is also a resource of its own, a
[`Sink`](/docs/reference/sinks/) or a
[`Source`](/docs/reference/sources/) with the same name. A claim
selects a device from the slice. The resource is where you read
what the endpoint is doing and set what it should rest at, with no
claim involved.

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, an HDMI output, and a microphone are the
same kind of device. All publish under `audio.liken.sh`, all select
the same way, and all 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`](https://kubernetes.io/docs/reference/kubernetes-api/resource/resource-slice-v1/)
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:
            sink: {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"}
            nodeName: {string: bluez_output.A0_AB_51_33_B7_12.1}
        - name: kitchen-pci-0000-00-1f-3-hdmi-0
          attributes:
            sink: {string: kitchen-pci-0000-00-1f-3-hdmi-0}
            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}
            nodeName: {string: liken.audio.card0-pcm3}
            monitor.liken.sh/id: {string: gsm-5b09-lg-ultrawide}
        - name: kitchen-usb-0573-1573-a34004801402-usb-audio-capture
          attributes:
            source: {string: kitchen-usb-0573-1573-a34004801402-usb-audio-capture}
            card: {int: 1}
            pcm: {int: 0}
            connectionType: {string: usb}
            nodeName: {string: liken.audio.card1-pcm0c}
        - name: kitchen-pci-0000-00-1f-3-hdmi-1
          attributes:
            sink: {string: kitchen-pci-0000-00-1f-3-hdmi-1}
            card: {int: 0}
            pcm: {int: 7}
            nodeName: {string: liken.audio.card0-pcm7}
          taints:
            - key: audio.liken.sh/disconnected
              effect: NoExecute
            - key: audio.liken.sh/no-monitor
              effect: NoSchedule

## The device classes

A consumer claims through a
[`DeviceClass`](https://kubernetes.io/docs/reference/kubernetes-api/resource/device-class-v1/)
that selects one direction of what this driver publishes. A sink
carries the `sink` attribute and a source carries `source`, so a
class selects on the presence of one:

    apiVersion: resource.k8s.io/v1
    kind: DeviceClass
    metadata:
      name: audio-sink
    spec:
      selectors:
        - cel:
            expression: has(device.attributes["audio.liken.sh"].sink)
    ---
    apiVersion: resource.k8s.io/v1
    kind: DeviceClass
    metadata:
      name: audio-source
    spec:
      selectors:
        - cel:
            expression: has(device.attributes["audio.liken.sh"].source)

You create these classes, because a class a workload claims through
is cluster policy, and this manual calls them `audio-sink` and
`audio-source` throughout; they are yours to rename or narrow, the
way a `StorageClass` is. A class alone allocates any endpoint of
its direction. To name one, add a selector on the attributes
below, as [Play sound to an output](/docs/guides/claim/) 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](https://liken.sh/docs/reference/devices/) in the `liken`
manual describes those raw devices.

## The attributes

The device name is built from the hardware's own identity, so it
survives a reboot and a second card: the node and the PCI address
for an onboard card, the node, vendor, product, and serial for a USB
card that has one, and the driver's own name for the PCM on the end,
`hdmi-0` or `usb-audio`. A capture endpoint's name ends in
`-capture`. A USB name that would pass 63 characters holds a hash of
the serial in place of the serial. The [`Sink` reference](/docs/reference/sinks/#the-name)
gives the three forms. The name repeats as the `sink` or `source`
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 |
|---|---|---|
| `sink` | string | a playback endpoint's name: `kitchen-pci-0000-00-1f-3-hdmi-0` or `a0-ab-51-33-b7-12` |
| `source` | string | a capture endpoint's name: `kitchen-usb-0573-1573-a34004801402-usb-audio-capture` |
| `connectionType` | string | `hdmi`, `displayport`, `analog`, `usb`, or `bluetooth` |
| `nodeName` | string | the PipeWire node name a consumer's streams target |
| `card` | int | the ALSA card number, this boot |
| `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"].sink`. The pairing
attribute is the one exception; it reads as the key `id` under the
domain `monitor.liken.sh`.

Every device carries exactly one of `sink` and `source`. The rest
divide by the kind of device that carries them: `card`, `pcm`, and
the monitor attributes are an ALSA endpoint's, and `address`,
`name`, `connected`, and `codec` are a speaker's.

* The monitor attributes, `manufacturer` through
  `monitor.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.
* `connectionType` is `analog` on the jack, `usb` on a USB card,
  and `bluetooth` on a speaker. On an HDMI or DisplayPort output it
  comes from the ELD block, so an output with no monitor publishes
  no connection type.
* `nodeName` is present while PipeWire holds a node for the device,
  and left out when the name passes the API's 64-character limit on
  a string attribute.
* `codec` is present only while the speaker is connected, because a
  codec is a property of a live transport, not of a pairing.
* `codecs` is present under the same condition as `codec`, 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](https://liken.sh/display/)
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, sink or source | 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 node name |

A Bluetooth speaker delivers the same three things, and so does a
source: `PIPEWIRE_NODE` then names the capture node, which
`target.object` honors for a capture stream as it does for
playback. One PipeWire holds the card's nodes 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 nodes PipeWire holds

The node 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
PCM devices through the ALSA control interface and writes one node
declaration for each into a PipeWire configuration drop-in, before
the daemons start: a sink for a playback PCM and a source for a
capture PCM. The node name is derived from the ALSA address alone,
`liken.audio.` plus the card and PCM numbers, with a `c` on a
capture node, so it is the same at every start on the same card:

    liken.audio.card0-pcm3
    liken.audio.card1-pcm0c

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 profile switching. Each sink's channel
layout comes from its `Sink`'s `spec.layout`, the monitor's ELD, or a
USB device's channel map, as the
[`Sink` reference](/docs/reference/sinks/#the-channel-layout)
describes, and a new layout restarts PipeWire. A node's
volume is PipeWire's software gain. The card's own mixer controls
are a separate surface, which the operator reads and writes through
the ALSA control interface and publishes on the endpoint's
[`Sink`](/docs/reference/sinks/) or
[`Source`](/docs/reference/sources/).

## 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. 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. A claim selects its
codec 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-sink

`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. A consumer's stream fader is
the consumer's own level. The endpoint's level is declared on its
`Sink` as `spec.volume.level`, which
[Set endpoint volume and controls](/docs/guides/rest/) shows, and an
endpoint with no declaration rests at unity.

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

