openspec/changes/archive/2026-09-10-add-parakeet-api/reviews/2026-09-10-advise.md
Advise: add-parakeet-api
ADVISE: send-back READER: agt-fable-5-1-arch-review SPAWN: /Users/dukejones/work/ClientProjects/AllSystemsGo/AICamera/.spawns/aicam-parakeet-shape-1789079544-12885-077901a4
Rigor: architecture. Density: standard. Reader family: Claude (Fable 5.1). Author family: grok.
Read order: packet Why → change spec specs/parakeet-asr/spec.md → package.json (adapter-static) → sim/3090/serve.py (bind_ip, port 8745) → daBOM src/lib/server/api/root.ts (well-known trio) →
daBOM src/lib/server/api/app.ts (app.doc31) → Papyrus morphist-asr/src/runtime/backend.rs (recommend_for_asr). Blind take written before proposal.md, steer.md, design.md, tasks.md were opened. No living parakeet-asr spec exists; capability is ADDED at fold.
Blind take (written before design.md)
- Pin. The OpenAPI document SHALL carry
serverswith the/api/v1base. daBOM’s well-known route isapi.request('/openapi.json'), so the bytes are identical, but an agent that reads/.well-known/openapi.jsonand seespaths./transcribewith noserverswill POST to/transcribeat root and get 404. Same bytes is necessary, not sufficient; the document must be self-locating. - Pin. Sidecar boundary. Bun does not run Parakeet TDT decode natively; the warm engine is a second process (Python + onnxruntime CPU or sherpa-onnx). The spec must say the sidecar listens on loopback or a unix socket only, and that the Bun process is the sole tailnet listener. Otherwise “Tailscale bind” is true of one process and a second unguarded port appears on the same box.
- Pin. CPU-only must be mechanical, not aspirational:
CUDA_VISIBLE_DEVICES=(empty) in the unit env, the CPUonnxruntimewheel notonnxruntime-gpu, and provider list hard-set toCPUExecutionProvider. Papyrusrecommend_for_asrclasses the 14600KF (Raptor Lake, AVX2+FMA, no AVX-512) asAvx2Fma→ CPU; the decision here is already made, so cite the rule and pin the env, do not port the detector. - Pin. CPU contention is the real cost, not VRAM. The 14600KF is 6P+8E and the same box runs the 30 fps compositor’s host-side work. A Parakeet session with default intra-op threads takes every core. The spec needs a thread ceiling (intra-op threads ≤ N, e.g. 4) and a scenario: transcribe while the 30 fps plugins run and frame time does not regress. “3090 stays free” without a CPU budget just moves the stall.
- Pin. Weights are a unit artifact, not a first-request download. Pin the INT8 ONNX bundle path (sherpa-onnx
parakeet-tdt-0.6b-v3-int8or NeMo export) and a checksum in the unit; the “warm sidecar serves the second call” scenario is meaningless if the first call is a multi-hundred-MB fetch. - Pin. Stream operation shape.
@hono/zod-openapicreateRoutecan declareapplication/octet-streamrequest +application/x-ndjsonortext/event-streamresponse, but not both response types on one operation. Pick one (NDJSON: symmetric with the PCM request, no browser SSE constraints). State that v1 stream clients are non-browser (curl, Bun, Node): streaming request bodies over fetch need HTTP/2 +duplex: 'half'which browsers do not do. End-of-stream is connection close; the last event carriesfinal: true. - Pin. Probe. 503 on sidecar-unavailable is stated but unobservable before a clip is posted. Add
GET /api/v1/healthin the OpenAPI document reporting sidecarwarm | cold | down, provider (CPUExecutionProvider), and model id, so the 503 scenario and the CPU-only scenario both have a cheap check. - Refuse. Any Hono/zod dependency in the root
package.json. The Pages build isvite buildwith adapter-static; the API must live in its own directory with its ownpackage.jsonand lockfile so the static site’s dependency graph does not grow a server. Root already showsbun.lockuntracked andpackage-lock.jsondeleted; do not let this change be the reason hono lands at root. - Refuse. Resampling or channel-mixing in v1. Wrong shape is 422, full stop. The moment the API resamples 44.1k stereo “to be helpful”, the 422 scenario is dead and the CPU budget in (4) is unknowable. WAV with a non-audio content-type is 415, not 422.
- Tradeoff. Python sidecar vs Bun-native ONNX (
onnxruntime-node). Bun-native is one process and no protocol, but TDT greedy decode + SentencePiece tokenizer must be re-implemented and maintained. Python (sherpa-onnx) is the supported Parakeet path today, costs a second process and a loopback protocol, and is what Papyrus deliberately did not do because it is Rust-native. Take the sidecar: the Bun process is the contract, the engine is swappable, and the “do not vendor” constraint stays honest.
Compare against proposal.md, steer.md, design.md, tasks.md
Also read after the take: sibling changes add-parakeet-engine, add-parakeet-hono, add-parakeet-stream (all ADD to parakeet-asr), daBOM app.ts:657 (servers: [{ url: '/api/v1' }]).
Shape matches the take. Standalone Bun + Hono + @hono/zod-openapi on fractal1, :8750 beside bay-feed :8745, Tailscale IPv4 only, daBOM well-known trio, warm Python onnx-asr INT8
sidecar, CPU EP with CUDA_VISIBLE_DEVICES= and four intra-op threads, windowed TDT for stream,
no vendoring of morphist-asr. The steer decided the same five things the take would have decided.
This change is the shape node; engine, hono, and stream do the writes, so a three-line tasks.md is correct, not thin.
Answered by the author (take → where):
- (2) sidecar boundary → design:
--stdin/IPC-->; no second port. Correct choice. - (3) mechanical CPU-only → design:
CUDA_VISIBLE_DEVICES=+providers=["CPUExecutionProvider"]. - (4) thread ceiling → design: “Four intra-op threads.” Engine proposal repeats it.
- (5) weights as artifact → engine tasks:
~/aicam/parakeet-cpuINT8 weights on fractal1. - (8) not in root
package.json→ engine tasks:asr/orsim/parakeet/. Directory undecided but off-root. - (10) sidecar over Bun-native → steer: Python
onnx-asrsidecar, decided.
Not answered, and each is a line in the spec that gets folded living. design.md and tasks.md do not fold; specs/parakeet-asr/spec.md does. Four of the author’s good answers live
only in design, and one design line contradicts the spec.
- Design contradicts spec on resampling. Spec, Clip transcribe: “Wrong sample shape SHALL be 422.” Design, Audio: “Other rates resample in the sidecar or 422.” The engine actor reads design. If the sidecar resamples, the 422 scenario is dead and the four-thread budget is unknowable. Strike “resample in the sidecar or” from design. Spec wins; say so.
- Health is promised in the journey and absent from the spec. Proposal, Empty: “model not
loaded yet. Health says so; transcribe is 503, not a hang.” No requirement produces a health
operation. Without it the 503 scenario is only observable by posting audio. Add
GET /api/v1/healthto the Well-known or Clip requirement: reports sidecarwarm|cold|down, provider, model id, and is listed in the document. - Sidecar listener is undecided across the DAG. Design says stdin/IPC. Engine tasks say “systemd unit Tailscale-adjacent to bay-feed, or stdin protocol the Bun process owns.” Tailscale-adjacent means a second tailnet port that the Tailscale-bind requirement does not cover (it names “the ASR process”, singular). One sentence under Tailscale bind: the sidecar SHALL NOT open a network listener; it is a child of the Bun process on stdin/IPC. That closes the engine “or” from the spec that owns the boundary.
- Thread ceiling and no-CUDA env are design-only. CPU-only Parakeet requirement says
CPUExecutionProviderand “SHALL NOT create a CUDA context” but not how. Add: the sidecar SHALL run withCUDA_VISIBLE_DEVICESempty and intra-op threads ≤ 4. The GPU scenario is already good; add one CPU scenario: transcribe while bay-feed’s 30 fps stack runs, frame time does not regress. That is the actual cost on this box, and the spec is silent on it. serversis the difference between “same bytes” and “self-locating”. daBOM setsservers: [{ url: '/api/v1' }]; “copy daBOM” implies it, but the hono sibling’s scenario accepts ”/transcribeor/api/v1/transcribeas declared”, which is exactly the ambiguity an agent trips on. Well-known requirement: the document SHALL carryserverswith the/api/v1base sopathskeys are relative to it. One line.
Notes, not blocking.
- NDJSON or SSE: spec, design, steer, and stream sibling all say “NDJSON/SSE”. Recommend NDJSON:
symmetric with the PCM request, no SSE framing, and browser SSE ergonomics do not matter because
streaming request bodies over fetch need HTTP/2 +
duplex: 'half'that browsers do not do. State v1 stream clients are non-browser. If the author keeps “as declared” that is acceptable; the document is the contract. - Four changes ADD to one new capability. This change carries the full spec; engine, hono, stream carry narrower requirements that overlap it (engine “Warm CPU sidecar” ⊂ api “CPU-only” + “Clip transcribe”). Fold this change first and fold the siblings as scenarios under its requirements, not as parallel requirements, or the living spec says the same thing three ways.
- Hardcoded default
100.103.147.70in the spec is fine as a default; the unit should resolve likeserve.pybind_ip()(env →tailscale ip -4→ default) and never fall through to0.0.0.0. Design already says never0.0.0.0. - WAV with a non-audio content-type is 415, not 422. Reserve 422 for declared-but-wrong shape.
Verdict. Send-back. The architecture is right and every decision in the steer holds. The send-back is five spec lines and one struck design clause, so the living spec carries what the design already knows. Re-advise should be short.