Set endpoint volume and controls

This guide shows how to change an endpoint’s volume, mute an output, close a microphone, and set a sound card’s controls with kubectl. These changes do not need a claim. They do not interrupt a pod that is playing or recording. You need the operator installed on your liken cluster.

Every output and input that the operator publishes has its own resource: a Sink for playback and a Source for capture. You write the settings you want in spec, and the operator writes the hardware’s facts and its latest readings in status.

The operator applies a volume and mute from spec when you change them and when the endpoint appears. It applies a control or a codec from spec again whenever the hardware drifts from it. None of this needs the speaker to be free: a pod can claim it and play while the operator applies the settings. The endpoint volume is separate from the volume of the pod’s own stream.

While a claim holds a Bluetooth speaker, the codec in the claim’s parameters applies. The codec in the Sink’s spec takes effect when that claim ends.

1. See what is there

kubectl get sinks
kubectl get sources

Each row is one endpoint: the node it is on, how it connects, the level and mute it was last read at, which claim holds it, and whether it is connected and ready. To see everything the operator reports about one, read the whole resource:

kubectl get sink kitchen-pci-0000-00-1f-3-hdmi-0 -o yaml

Read two parts of status. capabilities lists the controls the sound card offers for this endpoint, including the range or choices for each control. observed is the last value the operator read for each setting. The operator updates it when the hardware reports a change. Turn a knob on a USB DAC, press the volume button on a Bluetooth speaker, or let a client change the graph, and the new value shows here within about a second. An endpoint that nothing is playing through has no level of its own to read. observed then shows the level the operator last wrote to it, from your declaration or from a volume ask, which is the level it will start at. Until the operator writes one, it shows no level at all.

2. Set the volume

kubectl patch sink a0-ab-51-33-b7-12 --type merge \
  -p '{"spec":{"volume":{"level":40}}}'

volume.level is a percentage, where 100 is full level with no gain applied. For an output on the sound card, this is the software level PipeWire applies. For a Bluetooth speaker, it is the speaker’s own volume: the operator sends it over AVRCP when the speaker supports absolute volume, so the number on the speaker’s display moves too.

The change takes effect at once, even while a pod is playing through the speaker. The pod’s own stream volume is a separate control on top of this one, so the two never conflict.

The declared level is where the endpoint starts. The operator writes it when you change it and each time the endpoint appears: a speaker that reconnects, or a node that PipeWire builds again. Between those moments the level can move. A press of the speaker’s own button, a client that changes the graph, and a remote’s volume key all change it, and the operator reports the new level in observed and does not write your value back. An operator restart writes nothing, so the level a person chose before the restart stays. If you never declare a level, a node that PipeWire builds while the operator runs starts at 100.

A remote’s volume key reaches a Sink through the media operator. It writes each press as an ask in status.session.volumeAsk, and the operator applies each new ask once. volume.max is the highest level an ask sets, 100 unless you declare it, and volume.step is how far one press moves the level, 5 unless you declare it:

kubectl patch sink a0-ab-51-33-b7-12 --type merge \
  -p '{"spec":{"volume":{"level":40,"max":80,"step":2}}}'

A person at the speaker can still turn it above max.

3. Mute an output, or close a microphone

kubectl patch sink kitchen-pci-0000-00-1f-3-hdmi-0 --type merge \
  -p '{"spec":{"mute":true}}'

The television goes silent. A player that holds it keeps running, and it plays into silence until you set mute back to false.

The same field on a Source closes a microphone:

kubectl patch source kitchen-usb-0573-1573-a34004801402-usb-audio-capture \
  --type merge -p '{"spec":{"mute":true}}'

To close every microphone in the house at once, run that patch over the list from kubectl get sources.

4. Set a control on the sound card itself

Some cards have their own hardware controls: a USB DAC’s volume, a laptop codec’s headphone switch, an input selector on a card with several jacks. status.capabilities lists them under the names the kernel uses, and you set them in spec.controls under the same names. An integer control takes a number in its range, a switch takes on or off, and a selector takes one of its listed values:

kubectl patch sink kitchen-usb-0573-1573-a34004801402-usb-audio --type merge \
  -p '{"spec":{"controls":{"PCM Playback Volume":"96"}}}'

The operator checks the name and the value against the capability before it writes. A name the card does not have, or a value out of range, is skipped and logged rather than written:

kubectl -n liken-system logs ds/audio-operator | grep controls

Not every endpoint has controls. An HDMI output has only its IEC958 Playback Switch, because an HDMI PCM has no volume control of its own. Use volume.level for its level. A Bluetooth speaker has none.

5. Remove a declared setting

Remove the field, and the operator stops applying it. The hardware keeps whatever value it has at that moment, because the operator never makes up a value on its own. Removing volume also removes max and step, and a remote’s volume key then steps by the defaults:

kubectl patch sink a0-ab-51-33-b7-12 --type json \
  -p '[{"op":"remove","path":"/spec/volume"}]'

6. Set the channel layout

A sink of a sound card plays a multichannel stream on its own channels only when PipeWire knows the position of each one. The operator reads the positions from an HDMI monitor’s ELD and from a USB device’s channel map. The analog jack reports no speakers, so its streams play in stereo until you state the layout. See which layout each sink has:

kubectl get sinks

To play 5.1 through the analog outputs, list the positions in the order of the card’s PCM slots:

kubectl patch sink node-1-pci-0000-00-1f-3-alc257-analog --type merge \
  -p '{"spec":{"layout":["FL","FR","RL","RR","FC","LFE"]}}'

The same field overrides a monitor or a device that reports its layout wrong. A new layout restarts PipeWire, and the restart ends every stream on the machine. So the operator waits until nothing plays. While it waits, the Sink’s LayoutApplied condition is False with the reason AwaitingIdle. Remove the field to return the sink to the layout its hardware reports:

kubectl patch sink node-1-pci-0000-00-1f-3-alc257-analog --type json \
  -p '[{"op":"remove","path":"/spec/layout"}]'

The operator sends PCM and does not write the kernel’s channel map. So a height speaker plays only what a receiver makes from the other channels, and Dolby Atmos and DTS:X do not reach the receiver. The Sink reference gives the layouts the operator selects and the reasons.

Grant volume control

Because the resources are ordinary Kubernetes objects, RBAC decides who may change them. A role that can patch sinks but not sources fits a wall remote or a home automation rule that sets volume and must never touch a microphone:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: volume-remote
rules:
  - apiGroups: [audio.liken.sh]
    resources: [sinks]
    verbs: [get, list, watch, patch]

If two writers share one resource, server-side apply keeps them apart. A remote that applies only spec.volume.level under its own field manager and a person who sets spec.controls never overwrite each other. If they do collide on one field, the API server reports a conflict instead of silently taking the last write.