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:
- A
likencluster. The operator claims the sound card through Dynamic Resource Allocation (DRA) , from the devicesliken’s own driver publishes. Devices describes those. - A machine in that cluster with a sound card: a monitor with speakers on HDMI or DisplayPort, or something wired to the analog jack.
- For Bluetooth speakers, the
bluetooth-operatoron the same machine. Its media bus puts the sound server onbluetoothd’s bus. Thebluetooth-operatoris optional, and a machine with a card and no radio installs nothing extra. kubectlwith cluster-admin access. You create cluster-scopedDeviceClassesyourself, and the base creates aClusterRoleand twoCustomResourceDefinitions.
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:
-
sound-cardis wiring, and the base ships it, served atdeviceclasses.yaml. The operator’s own pod claims every sound device on its node through it, and the claim template in the servedoperator.yamlnames it literally, so the operator cannot start without it. Do not delete it. -
The classes your workloads claim through are yours to create, because they are your cluster’s vocabulary, and the base ships no policy.
audio-sinkandaudio-sourceare the ones to start with. Each covers one direction of what this driver publishes: a playback endpoint has thesinkattribute and a capture endpoint hassource: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)
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.