Transcription as a JOB with a visible lifecycle, and the transcriber itself as a REPLACEABLE ENGINE. Two things live here and nothing else:
The ENGINE REGISTRY. An engine is one map: an :id, a human :label and a
:transcribe fn of {:audio-path :on-progress} returning text. The local
Parakeet model is registered by vis-foundation-voice; a colleague who
wants a whisper.cpp server instead writes an extension that calls
register-engine! and changes NOTHING here, in the gateway, in the TUI
or in the app — the engine is chosen by VIS_VOICE_ENGINE, by
set-default-engine!, or per request.
The JOB STORE. Transcription used to be one blocking POST that either
returned text or timed out, so no surface could say where it was. A job
instead carries a PHASE and a 0..100 :progress readable at any moment:
:queued -> :preparing -> :transcribing -> :done | :failed.
:uploading is the client's own half — the bytes are still travelling and
no job exists yet — and is part of the vocabulary so every surface names
the same five things. Every change is PUSHED to watch! watchers, so
the gateway streams a job as SSE and no surface ever polls.
Transcription as a JOB with a visible lifecycle, and the transcriber itself as
a REPLACEABLE ENGINE. Two things live here and nothing else:
1. The ENGINE REGISTRY. An engine is one map: an `:id`, a human `:label` and a
`:transcribe` fn of `{:audio-path :on-progress}` returning text. The local
Parakeet model is registered by `vis-foundation-voice`; a colleague who
wants a whisper.cpp server instead writes an extension that calls
[[register-engine!]] and changes NOTHING here, in the gateway, in the TUI
or in the app — the engine is chosen by `VIS_VOICE_ENGINE`, by
[[set-default-engine!]], or per request.
2. The JOB STORE. Transcription used to be one blocking POST that either
returned text or timed out, so no surface could say where it was. A job
instead carries a PHASE and a 0..100 `:progress` readable at any moment:
`:queued` -> `:preparing` -> `:transcribing` -> `:done` | `:failed`.
`:uploading` is the client's own half — the bytes are still travelling and
no job exists yet — and is part of the vocabulary so every surface names
the same five things. Every change is PUSHED to [[watch!]] watchers, so
the gateway streams a job as SSE and no surface ever polls.Namespaces soft-required once before the registry is read. Each must expose a
0-arity register!. Soft: a build without the extension simply has no engine
and every surface answers "unavailable" instead of failing to load.
Namespaces soft-required once before the registry is read. Each must expose a 0-arity `register!`. Soft: a build without the extension simply has no engine and every surface answers "unavailable" instead of failing to load.
(default-engine)The engine used when a caller names none: VIS_VOICE_ENGINE first (an
operator outranks the code), then set-default-engine!, then the first
registered.
The engine used when a caller names none: `VIS_VOICE_ENGINE` first (an operator outranks the code), then [[set-default-engine!]], then the first registered.
Operator override naming the engine every surface should use.
Operator override naming the engine every surface should use.
(engine-error engine)nil when engine is a usable engine, else the one-line reason.
nil when `engine` is a usable engine, else the one-line reason.
(engines)Every registered engine, in registration order.
Every registered engine, in registration order.
(engines-info)The engine catalogue as capabilities data: what exists and what is selected.
The engine catalogue as capabilities data: what exists and what is selected.
(error-message t)One readable line for a Throwable: the DEEPEST cause's own sentence, so an
opaque wrapper (ExecutionException: java.lang.RuntimeException: no model)
never hides the line a human needs.
One readable line for a Throwable: the DEEPEST cause's own sentence, so an opaque wrapper (`ExecutionException: java.lang.RuntimeException: no model`) never hides the line a human needs.
(forget! id)Drop a job (a client that collected its transcript need not wait for the TTL).
Drop a job (a client that collected its transcript need not wait for the TTL).
Is this a phase of the shared vocabulary?
Is this a phase of the shared vocabulary?
Every transcription phase, in order. :uploading is client-side (the bytes are
in flight, the gateway has no job yet); a stored job starts at :queued.
Every transcription phase, in order. `:uploading` is client-side (the bytes are in flight, the gateway has no job yet); a stored job starts at `:queued`.
(prepare! engine)Ask the engine to start making itself ready (idempotent, NON-blocking) and return its readiness. A no-op for an engine that declares none.
Ask the engine to start making itself ready (idempotent, NON-blocking) and return its readiness. A no-op for an engine that declares none.
(public-engine engine)One engine in the shape every surface reports.
One engine in the shape every surface reports.
(public-job job)A job in the shape every surface reads. The audio path never leaves.
A job in the shape every surface reads. The audio path never leaves.
(readiness engine)What an engine says about its ability to transcribe RIGHT NOW, in the shape the
wire already speaks: {:state :ready|:absent|:downloading|:failed :progress? :phase? :error?}. An engine that needs no preparation (a remote server) simply
omits :model-state and is always ready — readiness is the ENGINE's question,
never a fact the gateway knows about one particular model.
What an engine says about its ability to transcribe RIGHT NOW, in the shape the
wire already speaks: `{:state :ready|:absent|:downloading|:failed :progress?
:phase? :error?}`. An engine that needs no preparation (a remote server) simply
omits `:model-state` and is always ready — readiness is the ENGINE's question,
never a fact the gateway knows about one particular model.(ready? engine)Can this engine take a recording right now?
Can this engine take a recording right now?
(register-engine! engine)Register (or replace, by :id) a transcription engine. Returns its id.
:transcribe is called with {:audio-path :on-progress :job-id} and returns
the transcript. :on-progress takes {:phase :progress} (either key optional,
:progress 0..100) and is how the human sees where the work is.
Register (or replace, by `:id`) a transcription engine. Returns its id.
`:transcribe` is called with `{:audio-path :on-progress :job-id}` and returns
the transcript. `:on-progress` takes `{:phase :progress}` (either key optional,
`:progress` 0..100) and is how the human sees where the work is.(reset-jobs!)Forget every job and every watcher (tests).
Forget every job and every watcher (tests).
(resolve-engine id)The engine for an explicit id (nil = the default). Throws with the ids that
DO exist, so a typo in VIS_VOICE_ENGINE reads as a typo.
The engine for an explicit `id` (nil = the default). Throws with the ids that DO exist, so a typo in `VIS_VOICE_ENGINE` reads as a typo.
(set-default-engine! id)Pin the engine used when a caller names none. nil clears the pin.
Pin the engine used when a caller names none. `nil` clears the pin.
(submit! {:keys [audio-path engine-id on-done]})Accept audio-path for transcription and return the QUEUED job immediately —
the caller answers 202 and the human is told the server took the recording.
The engine runs on its own thread; on-done (optional) receives the final
public job and is where a temp file is deleted.
An unknown engine is refused HERE, before a job exists, so the caller can answer 400 rather than inventing a job that instantly fails.
Accept `audio-path` for transcription and return the QUEUED job immediately — the caller answers 202 and the human is told the server took the recording. The engine runs on its own thread; `on-done` (optional) receives the final public job and is where a temp file is deleted. An unknown engine is refused HERE, before a job exists, so the caller can answer 400 rather than inventing a job that instantly fails.
(submit-sync! {:keys [audio-path engine-id on-done]})submit! without the thread — the same job lifecycle run inline. Tests and
any caller that wants determinism use this.
[[submit!]] without the thread — the same job lifecycle run inline. Tests and any caller that wants determinism use this.
(transcribe! {:keys [audio-path engine-id on-progress]})Run audio-path through the resolved engine on THIS thread, reporting
{:phase :progress} to on-progress. Returns the transcript.
Run `audio-path` through the resolved engine on THIS thread, reporting
`{:phase :progress}` to `on-progress`. Returns the transcript.(unregister-engine! id)Drop an engine by id (used by tests and by an extension being unloaded).
Drop an engine by id (used by tests and by an extension being unloaded).
(watch! job-id f)Call f with the PUBLIC job on every state change of job-id, until the
returned zero-arg fn is called.
This is what makes progress a STREAM rather than a poll: the gateway's SSE
body parks on a queue this fills, so a percentage reaches the human the
instant the engine reports it instead of on the next tick of a timer. f
runs on the ENGINE's own thread, so it must only ENQUEUE - a slow or throwing
watcher can never stall a transcription, and a throw costs that one delivery
and nothing else.
Call `f` with the PUBLIC job on every state change of `job-id`, until the returned zero-arg fn is called. This is what makes progress a STREAM rather than a poll: the gateway's SSE body parks on a queue this fills, so a percentage reaches the human the instant the engine reports it instead of on the next tick of a timer. `f` runs on the ENGINE's own thread, so it must only ENQUEUE - a slow or throwing watcher can never stall a transcription, and a throw costs that one delivery and nothing else.
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |