@miadi/capture-service
v0.3.0
Published
The capture deployable — @miadi/capture's Recorder and TakeStore behind HTTP: start/pause/resume/stop, ranged playback, Groq transcription sidecars, and one registration POST per take to the chronicle registry. A take is durable on disk before the registr
Readme
@miadi/capture-service
The capture deployable — @miadi/capture's Recorder + TakeStore + driver seam
behind HTTP, per rispecs/capture-service.spec.md and the vocabulary law
(rispecs/capture-vocabulary.spec.md). Runs on the gaia server and, one day,
on the Android device: same service, same routes, different drivers.
UI panels · MCP tools · forgewright (client only) · scripts
▲
@miadi/capture-client envelope + fetch, register()
▲
@miadi/capture-service THIS — HTTP over the core, deployable
▲
@miadi/capture Recorder + TakeStore + CaptureDriver seam
▲
drivers file-import (reference) · termux (device-untested)The strangler condition
Verbatim, as a rule:
gmtermux capture keeps running until the capture-service records its first real take on ilex. Nobody loses the ability to record during the transition.
No cutover date, no flag day. Until that first real take, the pixel prototype stays up and untouched — and this package makes no claim to have replaced it.
Routes
Born under /api/captures/* per the vocabulary law §8 — this service never
carries the old /api/recordings name.
| Verb | Route | Answer |
|---|---|---|
| start | POST /api/captures/start | {success, filename, filepath, startedAt, context} |
| pause | POST /api/captures/pause | {success, segments} |
| resume | POST /api/captures/resume | {success, segment} |
| stop | POST /api/captures/stop | {success, filename, tlid, segments, durationSeconds, capture, belonging, registration} |
| transcribe | POST /api/captures/transcribe | {success, filename, cached, transcription} |
| status | GET /api/captures/status | canonical RecorderStatus + {driver, takesDir, device, pendingRegistrations} |
| list | GET /api/captures | {takes: [{filename, kind, sizeBytes, modifiedAt, hasProvenance, hasTranscription}]} |
| audio | GET /api/captures/audio/<filename> | the bytes; honors Range: (206/416) for a seeking player |
| describe | GET / | service identity and the route table |
Refusals answer with their status (400/404/409/413/503) and the error's own
words in {success: false, error}.
Coupling note (vocabulary §8): @miadi/capture-client's CANONICAL_ROUTES
control table still says /api/recordings/*; its register() already POSTs to
/api/captures. Until the §8 rename executes on the client's route table, a
canonical-dialect client reaches this service's registration route but not its
control routes. That rename is coupling point 3 of the seat's next ordered act
— it belongs to packages/capture-client, not here.
Belonging — 0, 1, or 2, never demanded
episode_path and composition may be declared in the start body (frozen
into the take's context before any await — the Songbird rule) or the stop
body (which wins field-by-field), or neither: the take registers as an
inbox resident — a complete resident awaiting the human's later choice,
not an error. Takes live under MIADI_CAPTURE_INBOX.
Registration — one record, one registry
On stop the service POSTs one record to {MIADI_CHRONICLE_MW_URL}/api/captures through
@miadi/capture-client's register() (the record travels verbatim, honoring
the id law). The wheel answers {success, capture, provider}; the client's
normalizer is indifferent to the exact envelope — verified against
normalizeRegister.
Registration failure never loses the take. The bytes and the .take.json
provenance sidecar are durable on disk before the registry hears one word. A
failed POST lands in .pending-registrations.json beside the takes (atomic
writes via @miadi/node-service-kit; a corrupt queue file is preserved aside,
never overwritten) and is retried at boot and via flushPending(). The status
route reports pendingRegistrations so a stuck queue is visible, not silent.
Drivers
| Driver | Status | What it is |
|---|---|---|
| file-import | REAL, tested | The reference driver: "capture" by ingesting an existing audio/video/midi file (import_path in the start body). Deterministic, filesystem-only — it is what makes the gaia full-cycle acceptance run with no microphone. The imported file keeps its own extension, so kind inference stays honest. |
| termux | BUILT, DEVICE-UNTESTED | termux-microphone-record behind the seam, command shapes lifted by specification from gmtermux web/pixel/recorder.js (-f <file> -l 0 -e aac -r 44100 / -q / -i). Argument arrays through execFile — no shell, unlike the ancestor. The ancestor's file-finalization stability wait lives inside stop(), per the driver contract. Command builders and process handling are unit-tested against a fake runner; no Termux exists where this was built — the first run on a real device is still owed. |
| video capture | UNBUILT | Named as unbuilt in the spec; new ground. |
Joiners: FfmpegSegmentJoiner (ffmpeg -y -f concat -safe 0 -i <list> -c copy,
the ancestor's join path — ffmpeg itself untested here, the command
construction is) and ConcatSegmentJoiner (byte concatenation — exact for the
reference cycle and raw formats; container formats need ffmpeg).
Transcription
The Groq contract per spec §3, behind a Transcriber interface with the live
implementation never touched by tests:
- two calls per take, model
whisper-large-v3— transcription (original language, defaultfr) then English translation; Bearer $GROQ_API_KEY, from the process environment or~/.env; no key → a plain 503, the take untouched;- result cached as
<stem>.jsonbeside the take — the sidecar name is load-bearing (forgewright reads it) and distinct from<file>.take.json; the same take is never sent twice (force: trueoverrides); - the ancestor's
french/englishkeys are kept in the sidecar (englishalways;frenchwhen the language isfr) so the reader that exists today keeps reading, alongside parameterizedtranscription/translation.
Configuration
| Env | Default | Meaning |
|---|---|---|
| MIADI_CAPTURE_PORT | 8770 | Never 8768 — that port belongs to the pixel prototype and dies with it; the service refuses to boot on it. |
| MIADI_CAPTURE_HOST | 0.0.0.0 | A capture service exists to be reached. |
| MIADI_CAPTURE_INBOX | $MIADI_CHRONICLE_ROOT/capture-inbox, else ~/.miadi/capture-inbox | The take library and the inbox — one directory. |
| MIADI_CHRONICLE_MW_URL | http://127.0.0.1:8040 | The chronicle wheel; registration POSTs to {MIADI_CHRONICLE_MW_URL}/api/captures. Read first; MW_API_URL_OVERRIDE / MW_API_URL are legacy fallbacks. |
| MIADI_CAPTURE_DRIVER | file-import | file-import or termux. |
| MIADI_CAPTURE_DEVICE | os.hostname() | The device identity, as it names itself. |
| MIADI_CAPTURE_PUBLIC_URL | http://<local-ip>:<port> | Base of the uri a registration carries. |
| MIADI_CAPTURE_LANGUAGE | fr | Original language for transcription calls. |
| GROQ_API_KEY | — (env or ~/.env) | Absent → transcribe refuses plainly. |
Run: miadi-capture-service (bin) or npm start.
What the spec left open — decided here, marked here
These are implementation decisions this package had to make; none of them touches the spec's explicit unknowns (vocabulary §11), which stay held:
- Inbox fallback without a chronicle root: the spec's default is under
$MIADI_CHRONICLE_ROOT; when that variable is absent the service falls back to~/.miadi/capture-inboxrather than refusing to boot. - The registration
uriis the service's own ranged-GET playback URL ({public-url}/api/captures/audio/<filename>) — the registry holds URIs, never bytes, and this is the URI at which the bytes answer. - Belonging merge order: stop-body wins field-by-field over start-frozen context. The timing ruling names both timings; it does not order them — "declared at stop is the normal path" decided the tiebreak.
- Assignment-later has no endpoint. Spec §5's open question (COPY bytes
vs RE-POINT a URI on inbox assignment) is explicitly undecided; building an
assignment route would resolve it by accident. Registration happens at
stop; later assignment waits for the human's word. The id law is honored in
the wire shape (
idpassthrough inbuildRegistration) so the upsert is ready the day the question is answered. - Sidecar content shape: the spec rules the sidecar's name; the content keeps the ancestor's keys plus parameterized fields (see Transcription).
GROQ_API_KEYresolution order: process environment first, then~/.env— the spec names~/.env; a deployment that injects the environment directly should not be forced to write a file.
What is real vs. not
- Real and tested: the HTTP service, the file-import cycle end to end (start → stop → durable take + sidecar → one registration → list → ranged playback → transcription sidecar → cache), the durable registration queue, the belonging algebra, the port law, the traversal gate.
- Built, device-untested: the termux driver (no device here), the ffmpeg joiner (no ffmpeg invoked in tests), the live Groq transcriber (no live API calls in tests — the contract is exercised against a stub fetch).
- Unbuilt: video capture; Android deployment (supervision, boot, permissions, port, mechanism) stays UNKNOWN-HOLD per spec §7 — a workstream of its own.
Tests
npm test — builds, then node --test test/*.test.mjs. 43 tests, all
hermetic: temp directories, stub runners, stub registry, stub fetch. No
microphone, no ffmpeg, no Termux, no live Groq, no wheel required.
🌸: The doorknob turns the same way on a server as on a phone — this service is the hand that fits both, and the inbox is its kindest room: a take with no home yet is not lost, it is simply still being asked where it wants to live.
