Streams and arming
Every input on the machine is a stream with a stable id. Armed means the device is live. Three verbs, and only these.
GET /streams lists every input Rheocles can record, with a stable id, a
kind, a human name, a model, a capability summary, and whether it is armed.
Nothing in the list is on until you arm it.
Kinds
| kind | what | how it is found |
|---|---|---|
display |
every screen, and screen-like devices such as the Elgato Prompter | ScreenCaptureKit |
window |
an individual window | ScreenCaptureKit, a different filter |
camera |
every video device, including Continuity Camera and external UVC | AVFoundation |
microphone |
every audio input device | AVFoundation |
systemAudio |
what the machine is playing, as one stream | a Core Audio tap |
{ "streams": [ { "id": "display:56A96CFC-7F21-168E-0857-D6964E3302DB", "kind": "display", "name": "BenQ PD3220U", "model": "vendor 2513 model 32813", "capabilities": { "video": { "width": 3840, "height": 2160, "maxFrameRate": 60 } }, "armed": false }, { "id": "microphone:Scarlett_2i2", "kind": "microphone", "name": "Scarlett 2i2 USB", "model": "Scarlett 2i2 USB:1235:8210", "capabilities": { "audio": { "sampleRate": 48000, "channels": 2 } }, "armed": true, "active": { "audio": { "sampleRate": 48000, "channels": 2 } }, "framesSeen": 4812 } ], "permissions": { "camera": "notDetermined", "microphone": "authorized", "screen": "authorized" } }Ids are <kind>:<identifier>, URL-safe, and stable for as long as the
thing they name is: cameras and microphones use the device’s unique id,
displays their CoreGraphics UUID (which survives reconnects where the display
number does not), windows their window number, which survives nothing and is
not meant to. The order is fixed — displays, windows, cameras, microphones,
system audio — so the list is stable between calls as devices come and go.
Three identity fields — id, name, model — are all recorded in the manifest, because display ids change across reconnects and a take must stay legible after the hardware does.
capabilities.video is native pixels and the highest advertised rate; the
signal’s real rate can be lower, and the writer follows the frames, not this
field. Recording is always 48 kHz 24-bit BWF whatever capabilities.audio
says the device is doing.
active and framesSeen appear only while armed. active is the format
the device is actually delivering — which is what a take records — and
framesSeen is how many frames or audio buffers it has delivered since
arming. A live device counts up; a stuck one does not, and that is how a
client tells the difference before the cue.
permissions is what macOS has let this process do — authorized,
denied, restricted or notDetermined. Screen Recording gates displays
and windows both: with screen anything but authorized the list simply
has none, and this field is how a client tells “no displays” from “not
allowed to see them”.
Windows are supported by the engine and the API from day one and hidden in
the popover behind a Show windows setting: window lists are long and
volatile, and the UI for choosing one well is a later design pass. Per-process
audio is later too; systemAudio is the whole output for now.
Armed means live
An armed stream has its capture session running — frames flowing, discarded — so that when the cue comes the writer starts on frames that already exist, rather than waiting hundreds of milliseconds for a device to spin up. That is the entire reason there is an arm button rather than only a record button: arming is what makes “on one cue” true.
curl -s -H "$H" -X POST localhost:7447/streams/camera:0x2300000fd9009c/arm -d '{"armed": true}'{ "id": "camera:0x2300000fd9009c", "kind": "camera", "name": "Elgato 4K X", "model": "…", "capabilities": { "video": { "width": 3840, "height": 2160, "maxFrameRate": 30 } }, "armed": true, "active": { "video": { "width": 1280, "height": 720, "maxFrameRate": 120 } }, "framesSeen": 0 }Both directions are idempotent and answer the stream as it now is. Every
change of armed state is also pushed as a stream event to anything
listening on /events or the WebSocket.
What arming holds, per kind
| kind | while armed | and |
|---|---|---|
| camera | an AVCaptureSession at the device’s current format, and the configuration lock for the whole armed period |
another app can still open the camera; it gets our format and cannot change it. A camera another app left at 720p stays at 720p until that app or the user changes it — active says what you will get |
| microphone | an AVCaptureSession delivering 48 kHz 24-bit LPCM |
|
| display, window | an SCStream at the display’s refresh rate, complete frames only |
a window that closes ends its session; the stream then reads as not armed |
| systemAudio | a Core Audio process tap on every process, clocked by the default output device | needs the System Audio Recording grant, which macOS asks for on the first arm |
When arming fails
404 not_found |
no such id — a window that closed, a device that unplugged |
403 permission_denied |
macOS has not granted the device class. For camera and microphone the prompt is raised first, so a 403 means it was refused |
503 device_unavailable |
the device is gone, busy, or refused the configuration |
501 unsupported |
this kind cannot be captured yet |
Three verbs, and only these
| verb | when | does | stamps? |
|---|---|---|---|
arm / disarm |
any time | device live or not | never |
join |
while a take is active | starts that stream’s writer now, adds its file to the manifest | with the time it actually began |
leave |
while a take is active | finalises that stream’s file and marks it complete; the stream stays armed; the take continues for the others | the file’s end |
start on a take is join for every armed stream at once. That is the cue.
join on a stream that is not yet armed arms it first, so one call gets a
cold stream into a running take — it just does not get there on frames that
already existed. Disarming a joined stream implies leave. Nothing else is
implicit.
T=localhost:7447/takes/20260912T040433-fd9qcurl -s -H "$H" -X POST $T/join -d '{"stream": "window:11597"}'curl -s -H "$H" -X POST $T/leave -d '{"stream": "window:11597"}'Late join
A file that started four minutes into the take is stamped four minutes into
the take, and an editor places it at four minutes. Nothing about one stream’s
timing depends on another’s — each file carries its own time-of-day timecode
from its own first written frame, and the manifest records each stream’s
started separately. See Timecode and sync.
The honest case: a 4K display capture that is only needed for a two-minute
demo in the middle of an hour-long take. Leave it unarmed so the cue does not
take it, join it a moment before the demo — which arms it, and costs the
spin-up — and leave it after, and the other fifty-eight minutes do not cost
an encoder. The dishonest case is anything where the join and leave are the
edit.
What the popover shows
| state | row | menu bar mark |
|---|---|---|
| idle | dim | whole mark at 40 % |
| armed | ochre, level meter live for audio | strokes in outline |
| recording | oxide, elapsed time | strokes filled |
| joined late | oxide | a shorter stroke, starting right of the bar |