Install the operator

This guide installs audio-operator on a liken cluster. At the end, every physical audio output on the cluster is a device a workload can claim.

You need:

1. Check that the card publishes

The machine with the speakers must publish its audio controller as a device. Look for a device stamped sound.liken.sh/supportsSound: {bool: true} in that node’s liken.sh ResourceSlice, which is the fact the operator’s sound-card class selects:

kubectl get resourceslice <node>-liken.sh -o yaml

If no device has the stamp, the operator’s own claim will park and its pod will stay Pending. Claiming hardware explains why: liken publishes the whole card, and this operator claims it and publishes each of its outputs.

2. The device classes

A DeviceClass is cluster-scoped policy, yours to name and curate, the same convention a StorageClass follows. The classes split by owner:

Generic or specific

A class is the cluster’s vocabulary for a kind of device, and you choose its grain. audio-sink above is generic: it matches every playback endpoint, it keeps the class list short, and it leaves the choice of output to each claim’s CEL selector. A specific class holds the selector itself. A claim then names the class and writes no CEL, and you make the choice once, in cluster policy you control:

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

A class that names one monitor’s speakers through monitor.liken.sh/id works the same way, with the same has() guard on the attribute.

Start generic. When several workloads repeat the same selector, or when you want the choice in cluster policy rather than in each workload’s manifest, create a specific class.

3. Apply the manifests

This site serves the repository’s deploy/ directory as raw YAML, so the install needs no clone. Six files are the rest of the install:

kubectl apply -n liken-system \
  -f https://liken.sh/audio/deploy/crds.yaml \
  -f https://liken.sh/audio/deploy/deviceclasses.yaml \
  -f https://liken.sh/audio/deploy/rbac.yaml \
  -f https://liken.sh/audio/deploy/operator.yaml \
  -f https://liken.sh/audio/deploy/api.yaml

api.yaml binds one Role in kube-system, which -n would refuse, so that binding is in a file of its own. Apply it with no -n:

kubectl apply -f https://liken.sh/audio/deploy/api-authentication.yaml

crds.yaml holds the Sink and Source CustomResourceDefinitions, the resources the operator creates for every endpoint it publishes. The Sink and Source references describe them.

api.yaml holds audio-api, a Deployment and a Service that run once for the whole cluster, with the RBAC they need. The listen guide depends on it. A cluster that needs no capture API can leave this file out.

The -n flag places the ServiceAccount and the DaemonSet in liken-system, the namespace every liken cluster has. The ClusterRoleBinding’s subject names that namespace, so the binding only works there. DeviceClass is cluster-scoped, so the flag leaves it alone.

For GitOps, put your specific classes in a file of your own and point a Kustomization at it and at the served URLs. kustomize takes a raw YAML URL as a resource:

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
transformers:
  - |-
    apiVersion: builtin
    kind: NamespaceTransformer
    metadata:
      name: liken-system
      namespace: liken-system
    unsetOnly: true
resources:
  - classes.yaml
  - https://liken.sh/audio/deploy/crds.yaml
  - https://liken.sh/audio/deploy/deviceclasses.yaml
  - https://liken.sh/audio/deploy/rbac.yaml
  - https://liken.sh/audio/deploy/operator.yaml
  - https://liken.sh/audio/deploy/api.yaml
  - https://liken.sh/audio/deploy/api-authentication.yaml

The transformer sets liken-system only on the objects that state no namespace, so the binding in api-authentication.yaml stays in kube-system. A top-level namespace: field would move it, and the API couldn’t read the client certificate authority.

A clone works too: kubectl apply -k audio-operator/deploy/ from the repository applies the same files through deploy/kustomization.yaml .

4. Watch the operator find the outputs

The operator runs as a DaemonSet, so a pod lands on every node and no manifest names the machine with the speakers. Each pod claims every audio controller on its own node. On a node with no sound card, the claim finds no device and the pod parks Pending, which costs nothing. A node labeled audio.liken.sh/sound-card: none gets no pod, as Keep the pods off nodes with no sound card describes.

kubectl -n liken-system get pods -o wide

The pod is five containers from one image. A declare init container writes PipeWire’s sink declarations, PipeWire and WirePlumber run as sidecars, the operator container publishes what they hold, and the capture container serves the taps that audio-api forwards. On the machine with the card, the operator’s log reports the slice it wrote:

kubectl -n liken-system logs ds/audio-operator
audio.liken.sh: operating the audio controller on kitchen
slice: created generation 1, 3 devices, 0 tainted

The image is a file closure on scratch: no shell, no package manager. Use pw-dump to inspect the running sound server:

kubectl -n liken-system exec ds/audio-operator -c operator -- pw-dump

Three more tools ship in the same image, for the times the graph reads correctly and the sound is still wrong. Each runs as its own kubectl exec, with no shell between. pw-top -b -n 1 prints one reading of every node, and its ERR column counts the dropouts. pw-cli lists any object in the graph and writes a parameter on one with set-param, with no restart of the daemon. pw-metadata reads and writes the graph’s settings, for example clock.force-quantum.

kubectl -n liken-system exec ds/audio-operator -c operator -- pw-top -b -n 1
kubectl -n liken-system exec ds/audio-operator -c operator -- pw-cli info 0
kubectl -n liken-system exec ds/audio-operator -c operator -- pw-metadata -n settings

5. See the devices

The operator publishes one device for each PCM device on the claimed card, sinks and sources alike, into a ResourceSlice named <node>-audio.liken.sh:

kubectl get resourceslice <node>-audio.liken.sh -o yaml

The same endpoints are resources of their own, one Sink per playback endpoint and one Source per capture endpoint, named like the devices:

kubectl get sinks
NAME                                            NODE      CONNECTION   VOLUME   MUTE    CLAIM   CONNECTED   READY   AGE
kitchen-pci-0000-00-1f-3-hdmi-0                 kitchen   hdmi         100      false           True        True    2m
kitchen-usb-0573-1573-a34004801402-usb-audio    kitchen   usb          100      false           True        True    2m

kubectl get sources
NAME                                                    NODE      CONNECTION   VOLUME   MUTE    CLAIM   CONNECTED   READY   AGE
kitchen-usb-0573-1573-a34004801402-usb-audio-capture    kitchen   usb          100      false           True        True    2m

An output whose monitor answers publishes the monitor’s attributes. An HDMI output with no monitor publishes too, with taints, so a claim on it parks until a monitor arrives. Devices describes every attribute.

When the pod’s claim also allocated a Bluetooth media bus, the same slice holds one device for each paired Bluetooth speaker. A speaker that is switched off publishes with taints, the same way an HDMI output with no monitor does.

Now play sound to an output , or set an endpoint’s volume and controls .

Keep the pods off nodes with no sound card

The DaemonSet makes a pod on every node. On a node with no sound card, the claim matches no device, and the pod stays Pending. To make no pod on such a node, label the node audio.liken.sh/sound-card: none.

The DaemonSet in the base carries this node affinity, so no patch is needed:

affinity:
  nodeAffinity:
    requiredDuringSchedulingIgnoredDuringExecution:
      nodeSelectorTerms:
        - matchExpressions:
            - key: audio.liken.sh/sound-card
              operator: NotIn
              values: ["none"]

NotIn matches a node whose label has a different value, and also a node that has no such label. The Kubernetes page on set-based requirements gives this rule. So with no label the DaemonSet makes a pod on every node, and a node labeled none gets no pod. When a label changes, the DaemonSet controller deletes the pod from a node that no longer matches, and adds one to a node that matches again.

Declare the label in the node’s Machine, so the label is part of the record of the machine:

spec:
  nodeLabels:
    audio.liken.sh/sound-card: none

A Machine accepts a key in a liken.sh subdomain from liken 2026.09.28-002 on, and an older release refuses it, so on an older release use the kubectl label way below.

The liken machine operator applies the label to the running node. A machine that is demoted or installed again registers a new Node, and the Node has the label from registration. To run the pod on that node again, remove the key from spec.nodeLabels, and liken removes the label from the node. liken also removes a label that you set with kubectl before the Machine declared it, when the key leaves the spec.

You can also label the node with kubectl:

kubectl label node node-1 audio.liken.sh/sound-card=none

The label stays on the node across reboots. liken leaves it in place, because liken removes only the labels that a Machine declared. The label goes with the Node object: a machine that is demoted or installed again registers a new Node, and you label it again. To run the pod on that node again, remove the label:

kubectl label node node-1 audio.liken.sh/sound-card-

A patch of your own that sets a node affinity on this DaemonSet replaces the list of terms in the base, and the none term with it. Copy the none requirement into each term of your patch.

Pin a release

The site serves the manifests of the current main, and the images in the manifests name :latest. To pin a release instead, reference the operator’s kustomize base at the operator’s version, which is the release tag that last published it. The GitHub release for each tag lists every component and its version. One tag versions the manifests and the image together, so pin both to the same version:

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - https://github.com/liken-sh/liken//audio-operator/deploy?ref=<version>
images:
  - name: ghcr.io/liken-sh/audio-operator
    newTag: <version>

Running a development build

A push to main that changes the operator publishes a development build of it. Its version is the most recent release tag plus a suffix: 2026.09.03-007-dev-003-abcdef01 is three commits past release 2026.09.03-007, at commit abcdef01. Every image of the operator has the same version, and :latest still names the most recent release.

A development build has no git tag, so the manifests pin to the commit’s full sha, and the image pins to the version:

resources:
  - https://github.com/liken-sh/liken//audio-operator/deploy?ref=<full 40-character sha>
images:
  - name: ghcr.io/liken-sh/audio-operator
    newTag: 2026.09.03-007-dev-003-abcdef01

A git fetch by sha needs all forty characters; the eight in the version are not enough. The summary of the CI run for that commit gives the version.

The same build publishes the manifests as the OCI artifact oci://ghcr.io/liken-sh/audio-operator-deploy:<version>, with each image of the operator set to that version. A Flux OCIRepository can pull that artifact by the version, with no sha.

Remove the operator

Delete the manifests. Then delete the slice on each node that published one:

kubectl delete -n liken-system \
  -f https://liken.sh/audio/deploy/rbac.yaml \
  -f https://liken.sh/audio/deploy/operator.yaml \
  -f https://liken.sh/audio/deploy/api.yaml
kubectl delete -f https://liken.sh/audio/deploy/api-authentication.yaml
kubectl delete resourceslice <node>-audio.liken.sh

The API’s own Secrets and ConfigMap outlive the Deployment. Delete audio-api-tls, audio-capture-server, and audio-api-ca in liken-system by hand.

This leaves the DeviceClasses in place: sound-card from the base, and the consumer classes you created. Delete them when no other claim names them. Deleting crds.yaml deletes every Sink and Source with it, and the declarations they hold:

kubectl delete deviceclass sound-card audio-sink audio-source
kubectl delete -f https://liken.sh/audio/deploy/crds.yaml

The second step is yours because the operator never deletes its slice. A device that leaves the inventory while a claim still names it strands the kubelet’s prepare call. So the operator taints devices instead of removing them, and the slice outlives every pod.