API
The API lets you listen to the sound itself over HTTP. It taps what
a Sink plays or what a Source hears, and streams it to you as
WAV, FLAC, or Ogg Opus. RBAC decides who may listen, and every tap
writes an audit record on the object. Two processes are involved.
audio-api is a Deployment in liken-system. It finds the node of
the Sink or Source 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.
At a glance
| Item | Value |
|---|---|
| Service | https://audio-api.liken-system.svc |
CA ConfigMap |
audio-api-ca in liken-system |
| Discovery | /v1/audio |
| OpenAPI | /v1/audio/openapi.json |
Shipped ClusterRole |
audio-capture-viewer |
| Audit record | a Captured Event on the Sink or Source |
| Route reference | Routes |
The display and media operators have the same kind of API for a
Display and a Player. The three APIs share the same paths, the
same HTTP behavior, and the same standards, but no code.
Authentication
There are two ways to identify yourself: a client certificate or a Bearer token. The API checks for a certificate first, then for a token, in the same order as the Kubernetes API server.
Client certificate
If your TLS 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 the subject’s organization values. This is exactly how the
Kubernetes API server reads a client certificate, so the credentials
in your kubeconfig identify you here the same way they identify you
to kubectl. The API reads the authority from the ConfigMap
extension-apiserver-authentication in kube-system, which is where
the API server publishes it, and watches it for changes. A rotated
authority takes effect with no restart. A certificate from
any other authority ends the TLS handshake.
Bearer token
If the connection has no client certificate, the API looks for a
Bearer token. It authenticates the token with a TokenReview that
requires the audience audio-api. That requirement keeps every pod’s
ordinary API server token out of this API. It has a real cost for
OIDC users: a kubeconfig’s OIDC token has the OIDC audience, so a
person on OIDC also has to mint a ServiceAccount token.
Authorization
After it identifies you, the API sends a SubjectAccessReview for
the verb get on sinks/audio or sources/audio in the API group
audio.liken.sh, with the name of the object and an empty namespace,
because both kinds are cluster-scoped. An info route needs get on
the plain resource. The discovery and OpenAPI documents need
authentication but no authorization. Every route authorizes before it
reads anything, so a 403 never tells you whether a name exists.
Grants
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. That covers every route below the
discovery document: the two plain resources for the info routes and
the two subresources for the taps.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: audio-capture-viewer
rules:
- apiGroups: [audio.liken.sh]
resources: [sinks, sources, sinks/audio, sources/audio]
verbs: [get]
The same ClusterRole binds to a person. Use kind: User with the
common name from their certificate, or kind: Group with one of its
organization values.
Because the taps are subresources, an owner can write a narrower rule. This one grants the sound of one sink and nothing else, not even the info document next to it:
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: kitchen-listener
rules:
- apiGroups: [audio.liken.sh]
resources: [sinks/audio]
verbs: [get]
resourceNames: [kitchen-pci-0000-00-1f-3-hdmi-0]
A role that grants resources: ["*"] in audio.liken.sh already
includes the taps, and so does cluster-admin.
There is one existing side door this API does not close. The PipeWire
socket is delivered through claims, so any pod with any audio claim
on a node can already tap that node’s microphone and sinks, with no
RBAC and no record. Until that door is closed, a Source grant
controls only this API.
A muted Sink still delivers its signal to a tap. A sink’s monitor
ports carry what the sink receives, and spec.mute is applied after
them. Muting a speaker silences the room and changes nothing on this
route. A muted Source does tap as silence, because its mute is
applied before the ports the tap reads. Mute is not a way to keep a
speaker out of this API. The grant is.
Every request that returned bytes writes a Captured Event on the
Sink or Source, so kubectl describe sink tells you who listened
and when.
Routes
Every path has the form
/v1/{domain}/[namespaces/{ns}/]{plural}/{name}/{aspect}[.{ext}].
domain is the first label of the CRD’s API group, so
audio.liken.sh gives audio. A namespaced kind has
namespaces/{ns} before its plural, the same as Kubernetes. The
domain segment is in the path so that one ingress can later serve
every domain’s API under one host name without a path clash, and so
that a future video domain with its own sinks and sources fits next
to audio. That ingress is not part of v1. In v1, each API is its
own Service.
You choose the format with the extension or with Accept. You choose
the span with a W3C Media Fragments t= in the query. Any other
parameter that only changes the representation, such as the bitrate,
also goes in the query. No route accepts a request body, so 415
never occurs.
| Method | Path | Response | Notes |
|---|---|---|---|
| GET, HEAD | /v1/audio |
200 application/json |
The discovery document |
| GET, HEAD | /v1/audio/openapi.json |
200 application/openapi+json |
OpenAPI 3.1 |
| GET, HEAD | /v1/audio/sinks/{name} |
200 application/json |
The sink’s format and the routes that tap it |
| GET, HEAD | /v1/audio/sinks/{name}/audio |
200, negotiated | What the speakers play now. Default audio/wav |
| GET, HEAD | /v1/audio/sinks/{name}/audio.wav |
200 audio/wav |
PCM in a RIFF WAVE stream |
| GET, HEAD | /v1/audio/sinks/{name}/audio.flac |
200 audio/flac |
FLAC |
| GET, HEAD | /v1/audio/sinks/{name}/audio.opus |
200 audio/ogg; codecs=opus |
Ogg Opus |
| GET, HEAD | /v1/audio/sources/{name} |
200 application/json |
The source’s format and the routes that tap it |
| GET, HEAD | /v1/audio/sources/{name}/audio[.ext] |
as the sink routes above | What the microphone hears, in the same forms |
| OPTIONS | any of the above | 204, no body | Allow: GET, HEAD, OPTIONS (RFC 9110 sections 9.3.7 and 10.2.1) |
The Routes
page lists every route from the
OpenAPI document, with its parameters, responses, and fields. The
OpenAPI 3.1 document is generated from the router table, so the
routes, media types, query parameters, and problem types on this page
and in that document come from one source. It is at
/v1/audio/openapi.json
. Its media type,
application/openapi+json, is registered by
draft-ietf-httpapi-rest-api-mediatypes and is provisional until
that draft is published.
Query parameters
| Parameter | Applies to | Values | Default | Rejected with 400 when |
|---|---|---|---|---|
t |
every tap route | Media Fragments NPT: t=begin,end, t=begin, or t=,end. The interval is half-open, [begin, end) |
none: the tap runs until you close the connection | a form the grammar rejects; t=a,b with a >= b; a begin over 60 s (captureBeginMax); a repeated dimension |
bitrate |
audio and audio.opus |
the Opus bitrate in kbit/s per channel, 6 to 256 | opusenc chooses one from the sample rate |
WAV or FLAC; a value outside 6 to 256 |
t=0,5 gives five seconds and then ends the stream. t=5,7 discards
five seconds and then records two. With no end, or with no t= at
all, the tap runs until you close the connection.
audio.opus?bitrate=128 gives Opus at 128 kbit/s.
t= goes in the query because of Media Fragments section 3.1, “a URI
query produces a new resource”, and section 7.4, which says a query
may change the media type. The parser follows section 5.1.1: it
splits on & and = first and percent-decodes second, so
t=10%2C20, t=%6ept:10, and t=npt%3a10 all parse (section
6.1.1).
Media Fragments puts NPT’s zero at the start of the source media
(section 6.1.1). A live tap has no start, so this API puts the zero
at the instant the capture container accepts the request. The
clock: format that would say this directly is in the advanced Media
Fragments document, not in version 1.0. The capture container sends
the headers at once, starts pw-record at once, discards samples
until begin on its own clock, and stops the encoder at end. So
sample zero of the body is the request time plus begin, as long as
the pipeline started within begin.
Media Fragments tells a user agent to ignore an invalid, unknown, or
non-existent dimension (sections 6.2, 6.2.1, 6.3.1). This API returns
400 instead. A query produces a new resource, so a client that
asked for a span must not silently get something else.
Content negotiation
An extension names one fixed representation. With no extension, the
API chooses by Accept with q-values, per RFC 9110 section 12.5.1.
Ties resolve in the server’s order: WAV, FLAC, Opus. No Accept
header means audio/wav.
| Request | Response |
|---|---|
Accept absent, */*, or audio/* |
200, audio/wav, Content-Location: /v1/audio/sinks/kitchen/audio.wav |
Accept: audio/ogg; codecs=opus, audio/flac;q=0.5 |
Opus, the higher q |
Accept: audio/flac;q=0.5, audio/ogg;q=0.5 |
FLAC, the server’s order at equal q |
Accept: audio/wav;q=0, */* |
FLAC: q=0 excludes WAV, and the wildcard admits the rest |
Accept: audio/mpeg |
406, and acceptable lists the three types with their hrefs |
That table is the response to GET /v1/audio/sinks/kitchen/audio. On
audio.wav, Accept: audio/flac is a 406 that lists audio.wav,
audio.flac, and audio.opus. Accept: audio/vnd.wave gets WAV.
| Format | Media type | Aliases | Standard |
|---|---|---|---|
| RIFF WAVE | audio/wav |
audio/wave, audio/x-wav, audio/vnd.wave |
RFC 2361 for audio/vnd.wave, WHATWG MIME Sniffing section 6.2 for audio/wave |
| FLAC | audio/flac |
audio/x-flac, deprecated |
RFC 9639 section 12.1 |
| Ogg Opus | audio/ogg; codecs=opus |
audio/ogg |
RFC 5334 for audio/ogg and its codecs parameter, RFC 7845 section 9 for opus and the .opus extension |
The only RIFF WAVE name in the IANA registry is audio/vnd.wave from
RFC 2361, an informational RFC from 1998 that no browser, ffmpeg, or
mpv sends. The WHATWG MIME Sniffing Standard calls the RIFF signature
audio/wave. Clients use audio/wav, so that is what the API sends.
Response headers
Every response has these headers:
| Header | Value | Standard |
|---|---|---|
Content-Type |
the media type of the body | RFC 9110 |
Vary |
Accept, on every response, extension routes included |
RFC 9110 section 12.5.5 |
Link |
rel="service-desc" to the OpenAPI document, and rel="service-doc" to this page |
RFC 8631 |
Link |
on a response about one object: rel="describedby" with the absolute URL of the object in the Kubernetes API |
RFC 8288 section 3.1 |
A document (discovery, OpenAPI, or an info route) has an ETag and
Cache-Control: no-cache, and answers If-None-Match with a 304
(RFC 9110 section 8.8.3, RFC 9111 section 5.2.2.4). The ETag is the
build version, because the router table is compiled into the binary.
An info document adds rel="related" (RFC 4287, registered) to each
of its capture routes. A tap has these headers instead:
| Header | Value | Standard |
|---|---|---|
Cache-Control |
no-store |
RFC 9111 section 5.2.2.5 |
Accept-Ranges |
none |
RFC 9110 section 14.3 |
Content-Disposition |
inline; filename="<name>-<time>.<ext>" |
RFC 6266 |
Transfer-Encoding |
chunked, on HTTP/1.1 |
RFC 9112 section 7.1 |
Content-Location |
on the negotiated route only: the absolute path of the extension route that was served | RFC 9110 section 8.7 |
Link |
on the route with no extension: one rel="alternate" per fixed format |
RFC 8288 |
Content-Location is only on the negotiated route. RFC 9110 section
8.7 calls it “a more specific identifier for the selected
representation”. The same section says a GET on that URL returns
the same representation. That is not true for a live capture, and the
API does not claim it. Accept-Ranges: none says the same thing
about bytes: a live capture has no byte identity, and byte 44 of two
requests is two different moments.
Vary: Accept is on the extension routes as well, where Accept can
turn a 200 into a 406. RFC 9110 section 12.5.5 gives a second
reason for Vary: it tells the recipient that the response was
subject to negotiation. The describedby URL is absolute because a
relative reference would resolve against the wrong server.
Content-Disposition gives a browser the file name to save as. The
time in it is RFC 3339 UTC with the colons replaced by dashes, for
example kitchen-usb-0573-1573-a34004801402-usb-audio-2026-09-16T21-02-16Z.wav.
A colon is not a legal file name character on every system a browser
saves to. This is the only place the API writes a time in a
non-standard form.
The type attribute of a link has no media type parameters (RFC 8288
section 3.4.1). The API only uses registered relations. An extension
relation would go under https://liken.sh/rel/.
Link: <https://kubernetes.default.svc/apis/audio.liken.sh/v1alpha1/sinks/kitchen>; rel="describedby",
</v1/audio/sinks/kitchen/audio.flac>; rel="alternate"; type="audio/flac",
</v1/audio/sinks/kitchen/audio.opus>; rel="alternate"; type="audio/ogg"
(wrapped here for reading; it is one header line on the wire)
HEAD returns the same headers as GET. It takes no sample and does
not call the capture container (RFC 9110 section 9.3.2). A HEAD on
an error has no body (section 15.5).
Errors
Every error is an RFC 9457 problem document,
application/problem+json. type is a URL. When it is about:blank,
the title is the status phrase (section 4.2.1). instance is the
request path, then #, then the request id. The API’s log line for
the request has the same id. detail quotes the source of the error
verbatim: the stderr of pw-record, the stderr of an encoder, or the
status.message from the API server. A 406 has an acceptable
list of {"type", "href"} objects. On an extension route it lists
that route’s own type and its siblings, with their URLs (RFC 9110
section 15.5.7).
This table lists every status this API returns, the headers that come with it, and the standard it comes from.
| Status | When | Headers | From |
|---|---|---|---|
200 document |
/v1/audio, openapi.json, or an info route |
see Response headers | RFC 9110 sections 8.8.3 and 12.5.5, RFC 9111 section 5.2.2.4 |
304 |
If-None-Match matches a document’s ETag |
ETag, Vary: Accept |
RFC 9110 sections 13.1.2 and 15.4.5 |
200 tap |
a tap is running | see Response headers | RFC 9110, RFC 9111 section 5.2.2.5, RFC 9112 section 7.1, RFC 6266, RFC 8288 |
204 |
OPTIONS |
Allow: GET, HEAD, OPTIONS |
RFC 9110 section 10.2.1 |
400 |
a t= the grammar rejects, t=a,b with a >= b, a begin over captureBeginMax, a repeated dimension, an unknown query parameter, or a parameter the format does not take |
application/problem+json |
RFC 9457. This is a deliberate departure from Media Fragments, explained under Query parameters |
401 |
no client certificate and no token: WWW-Authenticate: Bearer realm="audio-api". A token the TokenReview refuses: the same header plus error="invalid_token" and an error_description with the review’s own words |
WWW-Authenticate |
RFC 9110 section 15.5.2, RFC 6750 section 3 |
403 |
the SubjectAccessReview said no |
WWW-Authenticate: Bearer realm="audio-api", error="insufficient_scope", scope="sinks/audio" |
RFC 9110 section 15.5.4, RFC 6750 section 3.1 |
404 |
no Sink or Source has that name, or PipeWire has no node for it |
application/problem+json |
RFC 9110 section 15.5.5 |
405 |
a method other than GET, HEAD, or OPTIONS | Allow |
RFC 9110 section 15.5.6 |
406 |
Accept excludes every representation the route can serve |
application/problem+json with acceptable |
RFC 9110 sections 12.5.1 and 15.5.7 |
409 |
the object is away: it has no status.node. detail tells you to power the device on |
application/problem+json, type away |
RFC 9110 section 15.5.10 |
500 |
the tap connected to a node other than the one you asked for | application/problem+json, type wrong-target |
RFC 9110 section 15.6.1 |
502 |
the capture container answered with something that is not HTTP, or not a problem document | application/problem+json |
RFC 9110 section 15.6.3 |
503 |
the capture container is at its tap limit, refused the connection, is not ready, has no certificate yet, or PipeWire refused pw-record |
Retry-After: 5, application/problem+json |
RFC 9110 sections 15.6.4 and 10.2.3 |
504 |
the capture container sent no headers within the header timeout | application/problem+json |
RFC 9110 section 15.6.5 |
The API uses 409 only where you can do something about it, and the
detail says what.
These are the problem types this API uses. The first five are shared with the display and media APIs. The last one belongs to this API.
| Type URI | Meaning | Status |
|---|---|---|
https://liken.sh/problems/no-node |
no Sink or Source has that name, or PipeWire has no node for it |
404 |
https://liken.sh/problems/not-acceptable |
Accept excludes every representation the route can serve |
406 |
https://liken.sh/problems/capture-busy |
the capture container is at its tap limit | 503 |
https://liken.sh/problems/upstream-failed |
the capture container answered something this API cannot relay, or sent no headers in time | 502, 504 |
https://liken.sh/problems/away |
the object is away: it has no status.node |
409 |
https://audio.liken.sh/problems/wrong-target |
the tap connected to a node other than the one you asked for | 500 |
Discovery
GET /v1/audio returns the discovery document. All three capture
APIs use the same shape. The templates are RFC 6570. {name} is
simple expansion (section 3.2.2), {.ext} is label expansion
(3.2.5), and {?t,bitrate} is form-style query expansion (3.2.8).
Form-style expansion percent-encodes the comma in t=5,7, and the
parsing order above accepts that form.
{
"resources": {
"sinks": {
"self": "/v1/audio/sinks/{name}",
"aspects": {
"audio": {
"template": "/v1/audio/sinks/{name}/audio{.ext}{?t,bitrate}",
"mediaTypes": ["audio/wav", "audio/flac", "audio/ogg"],
"extensions": ["wav", "flac", "opus"],
"redirects": false
}
}
},
"sources": {
"self": "/v1/audio/sources/{name}",
"aspects": {
"audio": {
"template": "/v1/audio/sources/{name}/audio{.ext}{?t,bitrate}",
"mediaTypes": ["audio/wav", "audio/flac", "audio/ogg"],
"extensions": ["wav", "flac", "opus"],
"redirects": false
}
}
}
},
"openapi": "/v1/audio/openapi.json"
}
Examples
There is no kubectl plugin in v1. The recipe below works through a
port-forward or through an ingress the cluster owner sets up.
kubectl -n liken-system create serviceaccount listener
kubectl create clusterrolebinding listener --clusterrole audio-capture-viewer \
--serviceaccount liken-system:listener
kubectl -n liken-system port-forward svc/audio-api 8443:443 &
kubectl -n liken-system get configmap audio-api-ca -o jsonpath='{.data.ca\.crt}' > ca.crt
curl --cacert ca.crt --resolve audio-api.liken-system.svc:8443:127.0.0.1 \
-H "Authorization: Bearer $(kubectl -n liken-system create token listener --audience audio-api --duration 10m)" \
"https://audio-api.liken-system.svc:8443/v1/audio/sinks/kitchen-pci-0000-00-1f-3-hdmi-0/audio.wav?t=0,5" \
-o kitchen.wav
If your kubeconfig has a client certificate, send that instead. You
need no ServiceAccount and no token. The port-forward is a TCP
tunnel, so the TLS handshake runs end to end and the certificate
reaches the API unchanged.
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
curl --cacert ca.crt --cert client.crt --key client.key \
--resolve audio-api.liken-system.svc:8443:127.0.0.1 \
"https://audio-api.liken-system.svc:8443/v1/audio/sinks/kitchen-pci-0000-00-1f-3-hdmi-0/audio.wav?t=0,5" \
-o kitchen.wav
From a pod on the cluster network, the same two files work against
https://audio-api.liken-system.svc/v1/audio/... with no
port-forward and no --resolve.
To listen live, use mpv with the URL in place of curl -o.
v1 has no CORS support, so a browser can only reach the API on the same origin through a port-forward.
Notes
The info route. GET /v1/audio/sinks/{name} returns the node,
the connectionType, the rate and channels a tap would use, the
status.format as reported, the formats served, and a
Link: rel="related" to each capture route. It costs one read from
the API server and one read of the PipeWire graph.
Metrics
Both processes serve liken_build_info, /healthz, /readyz, and
/metrics: the API on port 9200 in its own pod, and the capture
container on port 9201. The route label is the RFC 6570 template,
never the concrete path, so no endpoint name reaches Prometheus.
| Process | Metric | Type |
|---|---|---|
| audio-api | audio_api_requests_total{route,method,status} |
counter |
| audio-api | audio_api_request_seconds{route} |
histogram, time to headers |
| audio-api | audio_api_streams_active{aspect} |
gauge |
| audio-api | audio_api_certificate_expiry_seconds |
gauge |
| audio-capture | audio_capture_ready |
gauge |
| audio-capture | audio_capture_bytes_total{aspect,format} |
counter |
| audio-capture | audio_capture_seconds_total{aspect,format} |
counter, stream time |
| audio-capture | audio_captures_active{aspect} |
gauge |
| audio-capture | audio_capture_failures_total{reason} |
counter: connect, target, wrong-target, encoder, limit, certificate |
Only the capture container emits the capture counters, so nothing is counted twice.