Play sound to an output

This guide plays one workload’s sound through one physical output, from a Deployment: an internet radio player on the kitchen monitor’s speakers. It works the same for the analog jack. You need the operator installed on your liken cluster.

The flow is Dynamic Resource Allocation (DRA) end to end. A ResourceClaim names the output. The scheduler allocates one matching device and places the pod on that device’s machine. The container receives the PipeWire socket and the name of the sink its streams must reach.

1. Pick the output

List what a node offers:

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

Each device is one playback PCM device of the card, with the attached monitor’s facts as attributes. Write a CEL selector against them. If the Dynamic Resource Allocation (DRA) objects are new to you, read How the pieces fit first. Four useful forms:

# by output
device.attributes["audio.liken.sh"].output == "card0-pcm3"

# the analog jack
has(device.attributes["audio.liken.sh"].connectionType) &&
device.attributes["audio.liken.sh"].connectionType == "analog"

# by monitor, so the claim survives a re-cabling
has(device.attributes["monitor.liken.sh"].id) &&
device.attributes["monitor.liken.sh"].id == "gsm-5b09-lg-ultrawide"

# one Bluetooth speaker, by the address on its label
has(device.attributes["audio.liken.sh"].address) &&
device.attributes["audio.liken.sh"].address == "A0:AB:51:33:B7:12"

On a machine whose radio the pod claimed a media bus from, a device is also one paired Bluetooth speaker. A speaker carries its address, the name BlueZ reports, its connection state, and its codec, and a claim selects it the same way it selects an output.

Guard connectionType and monitor.liken.sh/id with has(), as above. On an HDMI or DisplayPort output both come from the monitor, so an output with no monitor publishes neither, and a selector that reads a missing attribute fails the whole allocation. output needs no guard, because every device publishes it. Guard address the same way: only a Bluetooth speaker publishes it, so an unguarded read fails the allocation on every one of the card’s outputs. Devices lists every attribute, and explains why the pairing attribute reads under its own domain, monitor.liken.sh.

2. Write the claim

apiVersion: resource.k8s.io/v1
kind: ResourceClaim
metadata:
  name: kitchen-speakers
  namespace: media
spec:
  devices:
    requests:
      - name: output
        exactly:
          deviceClassName: audio-output
          selectors:
            - cel:
                expression: |
                  device.attributes["audio.liken.sh"].output == "card0-pcm3"
          tolerations:
            - key: audio.liken.sh/disconnected
              operator: Exists
              effect: NoExecute
              tolerationSeconds: 30

Tolerate audio.liken.sh/disconnected and nothing else. Its effect is NoExecute, and tolerationSeconds says how long your pod may hold a silent output before the eviction controller ends it. Thirty seconds means a reseated cable costs nothing, and it also keeps the pod through a restart of the operator itself. Leave audio.liken.sh/no-monitor and audio.liken.sh/no-sink untolerated: they hold a new pod Pending until the output can play, and the pod starts on its own when it can.

3. Reference the claim from a Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
  name: kitchen-radio
  namespace: media
spec:
  replicas: 1
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app: kitchen-radio
  template:
    metadata:
      labels:
        app: kitchen-radio
    spec:
      resourceClaims:
        - name: output
          resourceClaimName: kitchen-speakers
      containers:
        - name: player
          image: <your mpv image>
          args:
            - --no-video
            - --ao=pipewire
            - https://radio.example.com/stream
          resources:
            claims:
              - name: output

One line makes this work: resources.claims gives the container the claim. That is what places the pod and delivers the socket. No flag hands over the sink, because PipeWire’s client library reads the two delivered environment variables itself: PIPEWIRE_REMOTE names the socket, and PIPEWIRE_NODE sets target.object on every stream the client creates.

The image is yours, with two requirements:

strategy: Recreate matters. Pods that share one ResourceClaim share its output, and PipeWire mixes streams. During a rolling update, the old and the new pod would both play through the one sink. Recreate ends the old pod first.

4. What the container receives

A mount and two environment variables. No device node: the container 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 output’s sink name, such as liken.audio.card0-pcm3

The mount is read-only because connecting to a Unix socket needs write permission on the socket itself, not on the directory that holds it.

One container holds at most one output. PIPEWIRE_REMOTE and PIPEWIRE_NODE each hold one value, so two allocations delivered to one container overwrite, and the last wins. A pod that plays into two outputs runs two containers, each naming its own request in the claim.

Choose the codec on a Bluetooth speaker

A speaker’s codecs attribute lists the A2DP codecs it offers, and a claim can state which one to play. The driver switches the transport before your pod starts. The case that wants this: on a busy radio, aptX holds its bitrate and chops, and SBC lowers its bitrate and holds together.

spec:
  devices:
    config:
      - opaque:
          driver: audio.liken.sh
          parameters:
            codec: sbc
    requests:
      - name: speaker
        exactly:
          deviceClassName: audio-output
          selectors:
            - cel:
                expression: |
                  has(device.attributes["audio.liken.sh"].address) &&
                  device.attributes["audio.liken.sh"].address == "A0:AB:51:33:B7:12"

Read the list the speaker offers before you name one:

kubectl get resourceslice <node>-audio.liken.sh \
  -o jsonpath='{.spec.devices[?(@.name=="a0-ab-51-33-b7-12")].attributes.codecs}'

A codec the speaker does not offer holds the pod in ContainerCreating, and the claim’s events name the offered list. A codec stated for one of the card’s own outputs fails the same way, because only a Bluetooth transport has a codec to choose.

A config block with no requests list applies to every request in the claim, and a requests list narrows it. codec is the only parameter this driver reads, and a key it does not read fails the prepare rather than playing something nobody asked for.

A DeviceClass can carry the same opaque block, which makes a codec cluster policy for every claim that allocates through that class. A claim that states its own codec wins over the class’s. Write the block in the claim when one workload needs a codec, and in the class when every workload through it does.

The switch takes a second or two, and the pod’s start waits for it. The speaker’s sink arrives at unity volume on every prepare, so set loudness in your player’s own stream volume, never by leaving a level on the sink.

Unplugged monitors, restarts, and a second claim

A monitor unplugged. The device keeps its place in the slice and gains the disconnected taint. After your tolerationSeconds, the eviction controller ends the pod. A cable reseated within the toleration costs nothing. A claim that selects by monitor.liken.sh/id instead of by output follows the monitor to whichever output its cable lands on next.

A PipeWire restart ends every client’s audio. The socket belongs to the PipeWire container, so its restart takes the socket away. A client that reconnects finds the new socket at the same path; a client that does not has to restart. The operator’s own restart takes nothing away, because the daemons run in their own containers and keep playing through it.

A second claim on the same output parks. Every device this operator publishes is exclusive, so the second pod waits Pending until the first releases the output. A sink can be shared and this one is not records that decision.

To put sound and picture on one monitor, continue with Pair sound with its screen.