Listen to what a speaker plays
This guide shows you how to tap an endpoint for a fixed span and
save it as a file: what a speaker plays, or what a microphone hears,
as WAV, FLAC, or Ogg Opus. You need the operator
installed
on your
liken
cluster and a connected
Sink
or
Source
.
audio-api serves the taps. It is a Deployment in liken-system
that finds the endpoint’s node and forwards the stream. The
capture container in the audio-operator pod on that node reads
the sound from PipeWire. Nothing is stored on either side. The
API reference
has the full contract. This
guide is the short path through it.
The kubectl liken audio capture command
The short path is the CLI. liken plugins sync installs the
kubectl liken audio plugin, as
Install the plugins
describes. kubectl liken audio capture streams a
Sink’s sound to stdout as WAV, so a file or a pipe is a single
command:
kubectl liken audio capture kitchen-pci-0000-00-1f-3-hdmi-0 > kitchen.wav
kubectl liken audio capture kitchen-pci-0000-00-1f-3-hdmi-0 | mpv -
--format flac and --format opus write those forms instead, and
--source taps a Source, a microphone, in place of a Sink. A
Sink and a Source are cluster-scoped, so the command takes no
namespace.
The CLI compares its own version with the operator’s. When they
differ, it warns on stderr and tells you to run
kubectl liken plugins sync. --force silences that warning and the
tap runs either way. --version prints the CLI’s version and exits.
The CLI authenticates with the client certificate in your
kubeconfig, the same subject kubectl uses, so the grant that step
1 describes is all it needs. It opens its own port-forward to
audio-api and reads the stream through it, so it needs no
in-cluster routing and runs from a laptop.
The endpoint argument completes to the names the cluster reports.
kubectl runs the plugin’s completion shim on its own, so
kubectl liken audio capture <TAB> lists the Sinks. For a direct
call to kubectl-liken-audio, load the script with
source <(kubectl liken audio completion bash).
The numbered steps below are the HTTP contract the CLI calls, for an
application in the cluster or a tap you bound with t=.
1. Who may listen
You can identify yourself with a client certificate or with a Bearer token. The API checks for a certificate first, then for a token, in the same order as the Kubernetes API server.
If your connection presents a client certificate signed by the
cluster’s own certificate authority, you are that certificate’s
subject. Your user name is the subject’s common name, and your
groups are its organization values. The credentials in your
kubeconfig identify you here the same way they identify you to
kubectl. A certificate from any other authority ends the TLS
handshake.
If you present no certificate, send a Bearer token. The API
verifies it with a TokenReview that requires the audience
audio-api. A pod’s ordinary API server token does not have that
audience, so it does not work here.
After it identifies you, the API sends a SubjectAccessReview
for the verb get on sinks/audio or sources/audio in the group
audio.liken.sh, with the name of the object and an empty
namespace, because both kinds are cluster-scoped. Every route
authorizes before it reads anything, so a 403 never tells you
whether a name exists.
The operator ships one ClusterRole for a cluster owner to bind:
audio-capture-viewer. It grants get on sinks, sources,
sinks/audio, and sources/audio: the two plain resources for the
info routes and the two subresources for the taps. Read your own
subject from your kubeconfig:
kubectl config view --raw --minify \
-o jsonpath='{.users[0].user.client-certificate-data}' \
| base64 -d | openssl x509 -noout -subject
Then bind the role to the common name that command printed:
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: audio-listener
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: audio-capture-viewer
subjects:
- kind: User
name: <the common name>
To bind a group instead, use kind: Group with one of the
certificate’s organization values as the name.
An application gets the grant through its ServiceAccount:
apiVersion: v1
kind: ServiceAccount
metadata:
name: audio-listener
namespace: liken-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: audio-listener
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: audio-capture-viewer
subjects:
- kind: ServiceAccount
name: audio-listener
namespace: liken-system
Because the taps are subresources, you can write a narrower rule. A
rule with resources: [sinks/audio] and a resourceNames list
grants the sound of one speaker and nothing else, not even the info
document next to it. A role that grants resources: ["*"] in
audio.liken.sh already includes every tap, and so does
cluster-admin.
Every request that returned bytes writes a Captured Event on the
Sink or the Source.
2. Reach the API
audio-api is a ClusterIP Service at
https://audio-api.liken-system.svc. It serves HTTPS with its own
certificate authority. That authority’s certificate is in the
ConfigMap audio-api-ca in liken-system, under the key
ca.crt.
A port-forward is a single TCP connection through the API server. A short tap works through one, and the API reference’s own recipe does that. For a long tap, read from a pod on the cluster network, which is what the rest of this guide does.
Put your client certificate and its key in a Secret that the pod
can mount:
kubectl config view --raw --minify \
-o jsonpath='{.users[0].user.client-certificate-data}' | base64 -d > client.crt
kubectl config view --raw --minify \
-o jsonpath='{.users[0].user.client-key-data}' | base64 -d > client.key
kubectl -n liken-system create secret tls audio-client \
--cert client.crt --key client.key
Write the pod to listen-pod.yaml:
apiVersion: v1
kind: Pod
metadata:
name: listen
namespace: liken-system
spec:
restartPolicy: Never
containers:
- name: curl
image: curlimages/curl:8.22.0
command: [sleep, "3600"]
volumeMounts:
- name: ca
mountPath: /ca
readOnly: true
- name: client
mountPath: /client
readOnly: true
volumes:
- name: ca
configMap:
name: audio-api-ca
- name: client
secret:
secretName: audio-client
The pod is in liken-system because a volume can only read a
ConfigMap or a Secret from the pod’s own namespace. The pod
sleeps for an hour and then exits, so a pod you forget does not run
forever.
kubectl apply -f listen-pod.yaml
kubectl -n liken-system wait --for=condition=Ready pod/listen --timeout 60s
An application needs no Secret. It runs as the ServiceAccount
you bound in step 1, mounts a token for the API’s audience, and
sends it as Authorization: Bearer:
volumes:
- name: token
projected:
sources:
- serviceAccountToken:
audience: audio-api
expirationSeconds: 3600
path: token
3. Take the tap
List the endpoints and pick one:
kubectl get sinks
kubectl get sources
Every command below runs in the pod from step 2 and writes its file
there. --fail-with-body makes curl exit non-zero on an error and
still write the problem document, which step 4 reads.
Five seconds of a speaker, as PCM in a RIFF WAVE stream:
kubectl -n liken-system exec listen -- curl -sS --fail-with-body \
--cacert /ca/ca.crt --cert /client/tls.crt --key /client/tls.key \
-o /tmp/speaker.wav \
'https://audio-api.liken-system.svc/v1/audio/sinks/kitchen-pci-0000-00-1f-3-hdmi-0/audio.wav?t=0,5'
Thirty seconds of the same speaker, as FLAC:
kubectl -n liken-system exec listen -- curl -sS --fail-with-body \
--cacert /ca/ca.crt --cert /client/tls.crt --key /client/tls.key \
-o /tmp/speaker.flac \
'https://audio-api.liken-system.svc/v1/audio/sinks/kitchen-pci-0000-00-1f-3-hdmi-0/audio.flac?t=0,30'
The same span as Ogg Opus, at 96 kbit/s per channel:
kubectl -n liken-system exec listen -- curl -sS --fail-with-body \
--cacert /ca/ca.crt --cert /client/tls.crt --key /client/tls.key \
-o /tmp/speaker.opus \
'https://audio-api.liken-system.svc/v1/audio/sinks/kitchen-pci-0000-00-1f-3-hdmi-0/audio.opus?bitrate=96&t=0,30'
Ten seconds of a microphone, as WAV:
kubectl -n liken-system exec listen -- curl -sS --fail-with-body \
--cacert /ca/ca.crt --cert /client/tls.crt --key /client/tls.key \
-o /tmp/microphone.wav \
'https://audio-api.liken-system.svc/v1/audio/sources/kitchen-usb-0573-1573-a34004801402-usb-audio-capture/audio.wav?t=0,10'
Two seconds, starting five seconds from now, which skips a fade-in:
kubectl -n liken-system exec listen -- curl -sS --fail-with-body \
--cacert /ca/ca.crt --cert /client/tls.crt --key /client/tls.key \
-o /tmp/later.wav \
'https://audio-api.liken-system.svc/v1/audio/sinks/kitchen-pci-0000-00-1f-3-hdmi-0/audio.wav?t=5,7'
The query parameters
| Parameter | What it does |
|---|---|
t= |
A W3C Media Fragments time range in seconds, as t=begin,end, t=begin, or t=,end. The interval is half-open. Zero is the instant the capture container accepts the request. t=5,7 discards five seconds and then records two. A begin over 60 seconds is a 400. Without an end, or without t= at all, the tap runs until you close the connection, so always give an end for a file. |
bitrate= |
The Opus bitrate in kbit/s per channel, 6 to 256. If absent, opusenc picks one from the sample rate. A bitrate on WAV or FLAC is a 400. |
The extensions are .wav, .flac, and .opus. A path with no
extension negotiates on Accept and returns audio/wav if you send
none.
4. Check what you got
Copy a file out of the pod and read it with ffprobe:
kubectl -n liken-system cp listen:/tmp/speaker.flac speaker.flac
ffprobe speaker.flac
The stream has the sample rate and channel count that the endpoint’s info route reports:
kubectl -n liken-system exec listen -- curl -sS --fail-with-body \
--cacert /ca/ca.crt --cert /client/tls.crt --key /client/tls.key \
https://audio-api.liken-system.svc/v1/audio/sinks/kitchen-pci-0000-00-1f-3-hdmi-0
A WAV or Opus file reports the t= span as its duration. A FLAC
file reports no duration, because the encoder writes the header
before the live tap ends. Decode it to measure it:
ffmpeg -i speaker.flac -f null -
The cluster’s own record of the tap is an Event. A Sink and a
Source are cluster-scoped, so their Events are in the default
namespace:
kubectl get events --field-selector reason=Captured
kubectl describe sink tells you who listened and when.
A tap of a speaker that plays nothing is silence, and so is a tap of
a muted microphone. A muted speaker is not silent. A sink’s monitor
ports carry what the sink receives, and spec.mute is applied after
them, so muting a speaker silences the room and changes nothing on
this route.
When a tap is refused
Every error is an application/problem+json document with type,
title, status, detail, and instance. detail quotes the
source of the error, such as the stderr of pw-record. curl wrote
the document to the output file, so read that file:
kubectl -n liken-system exec listen -- cat /tmp/speaker.wav
| Status | What it means | What to do |
|---|---|---|
| 401 | No client certificate and no token, or the TokenReview refused the token |
Check that the Secret has the certificate and key from the kubeconfig you use, or mint a token for the audience audio-api |
| 403 | The SubjectAccessReview said no |
Bind audio-capture-viewer to your subject, as step 1 shows. The WWW-Authenticate header names the scope you need |
| 409 | The endpoint has no status.node |
The device is away. detail tells you to power it on |
| 503 | The capture container is at its tap limit, refused the connection, is not ready, has no certificate this API trusts, or PipeWire refused pw-record |
The response has Retry-After: 5. Wait five seconds and try again |
5. Clean up
kubectl -n liken-system delete pod listen
kubectl -n liken-system delete secret audio-client
rm client.crt client.key
The ClusterRoleBinding from step 1 is a standing grant. If the tap
was a one-off, delete it too:
kubectl delete clusterrolebinding audio-listener
The side door this grant does not close
The PipeWire socket is delivered to pods through claims, so any pod
with any audio claim on a node can already tap that node’s
microphones and sinks, with no RBAC and no record. Until that door
is closed, a Source grant controls only this API.