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.