Decision 0091
0091. A turn ends in words when this machine has something that can produce them
Architecture decision record 0091: 0091. A turn ends in words when this machine has something that can produce them
Status: accepted. Extends ADR-0075, whose last consequence was that a turn ends in a loudness and not in words.
Context
ADR-0075 built the whole of a spoken turn except its ending. A key is held, the microphone opens for exactly as long as the hand is on it and no longer, the utterance is bounded at thirty seconds so that a stuck key is not an open microphone until reboot, speech yields to speech in both directions, and the indicator is the state rather than a flag beside it. What came out the far end was a measurement — how many samples, how long, the loudest and the average — because nothing on the machine could turn audio into a sentence.
Its own record said why, and said it as a prediction: “one missing artefact and not a missing
design”. That was half true. The artefact really is missing — every entry in models/registry.toml
is refused by name, because filling in a URL and a hash is an act somebody performs with the file
in front of them, and no machine built from this tree can fetch a weights file. But the design was
missing too, and calling it finished hid that: there was no trait, no client, no field on the wire
for words, and no test anywhere that a turn could carry a sentence. “Waiting on a model” was
describing two things at once, one of which nobody was waiting for.
The two are worth separating because they fail differently. A missing model is a row somebody adds with a file in front of them. A missing seam is work, and work that is invisible until the row exists is work that gets discovered on the day somebody expects the machine to listen.
Decision
A Transcriber is a trait, and a turn asks one when this machine has one. tacid-voice gains
transcribe: the trait, the WAV encoding an engine wants, and a Transcript with two variants —
Said(String) and Unasked. Two variants and not an Option<String> at the seam, because the
name is the argument: a machine with no engine did not fail to hear words, it did not ask, and
that is a different answer from silence.
The client for whisper.cpp lives in tacid-session. tacid-voice is the device layer and the
state machine — what a turn is — and neither half of that speaks HTTP. The one process that holds
a person’s microphone is their executor, so the process that makes the call is the same one, and
what whisper.cpp’s server wants in a request belongs where the request is made.
It is reached at a Unix socket and never at a port. whisper.cpp v1.9.2 calls
bind_to_port(hostname, port) and has no AF_UNIX path anywhere in it, so tacid-infer runs it
inside its own network namespace and pumps bytes between a loopback port there and a path with a
mode on it out here (ADR-0086).
This end sees a socket like every other engine on the machine.
Whether this machine can transcribe is decided once, at start-up, by whether the socket is
there. The supervisor opens each engine’s door before it starts the engine, precisely so a
caller who arrives first waits at a socket rather than being told there is none — so a path that
exists means a machine configured for one. A machine that is not gets no client at all, and its
turns end in Unasked rather than in a connection error every time somebody presses a key.
The measurement stays, and not as a fallback. A turn that was transcribed carries the loudness
too. It is what tells a person their microphone is muted when the words come back empty, it is what
checks.*.voice asserts against a loopback card, and a machine that reported nothing when its
engine was down would look to its owner like a machine whose microphone had stopped working.
A transcriber that fails fails the turn. It does not return an empty sentence. An empty sentence is indistinguishable from a person who said nothing, and the agent would act on it.
The words are untrusted, exactly as before. They arrived because somebody in the room made a
noise, which is the weakest provenance anything on this machine has. The transcript reaches the
daemon as Provenance::Voice and the keeper decides what may come of it. Nothing in this record
launders anything: it is a string arriving by a new route, and the route was already accounted for.
Consequences
VoiceHeardResult.text is Option<String> and is absent rather than empty. The wire carries
the same distinction the seam does, so a surface can print “this machine has no transcriber” for
one and nothing at all for the other. tacid voice release prints the sentence first when there is
one — it is what the person asked for — and the measurement under it, which is what explains a
sentence that did not arrive.
The result stopped being Copy, which is what a String in a struct costs and is worth
saying: every caller clones it now, and every caller was cloning something anyway.
The stand-in transcriber is what makes the seam finished work. Everything a turn does with words — carrying them, keeping the measurement beside them, telling “nothing was asked” from “nothing was said”, failing honestly — is asserted against a transcriber that answers from a table, and the client itself is asserted against a real HTTP server on a real Unix socket that hands back the request it was sent. What is not asserted anywhere is whisper.cpp’s own behaviour, and that is the honest boundary: the first machine with a model is the first machine that finds out whether the request is shaped the way that program wants.
Two of whisper.cpp’s answers look alike and are not. It replies {"error": …} with a 200
status, so a client that read one field would hand the daemon a transcript of nothing and call it a
turn. And it pads every transcript with a leading space and a trailing newline, so the sentence is
what is between them. Both are checked.
The wait is bounded, and that was a defect in the first version of this. release awaits the
transcriber before it answers the person who let go of the key, and awaiting it without a ceiling
means an engine that accepted the connection, read the request and stopped holds a spoken turn open
forever, with no signal and no way back —
ADR-0062
arriving in the one place where the thing waiting is a person with their hand still on a keyboard.
The ceiling is four times what was actually said, floored at thirty seconds: scaled because a
two-second utterance held open for a flat minute is a person watching nothing happen, floored
because the number that matters for a wedged engine is any number at all, and four because
PLAN.md §3.14 promises an eight-second utterance in a second and a half on the reference machine.
What it catches is the engine that was never going to answer, not the slow one.
The multipart boundary is fixed and the body is searched for it. A WAV of somebody speaking is arbitrary bytes; the odds are astronomical and the failure it produces is a truncated request and a transcription error nobody could reproduce. Looked for rather than reasoned about.
What is left is one row. models/registry.toml names whisper.cpp, the supervisor can start
it, the door is held, the client is written and the wire carries what comes back. A weights file
with a verified hash is the whole of the remaining distance, and it is an act for somebody with the
file in front of them.
Alternatives rejected
Waiting for the model before building the seam. It is what ADR-0075 decided and it is what this overturns. A design nobody has written is not a design that is finished; the cost of finding that out is paid on the day somebody first expects the machine to listen, which is the worst day to pay it.
Option<String> at the seam instead of Transcript. It carries the same two states and names
neither. None reads as “no words”, which is true of both a mindless machine and a silent room,
and the code that had to tell them apart would be doing it by looking at a field somewhere else.
A streaming interface. Partial transcripts are worth having and this is not the moment: the bound is thirty seconds, which at 16 kHz is under a megabyte, and a streaming protocol is a thing every implementation has to agree on. When there is a second implementation that can stream, that is when to have the argument.
Putting the HTTP client in tacid-voice. It would let a future caller that is not an executor
reuse it, and there is no such caller: a microphone belongs to a person and a person’s process is
their executor. The cost would be hyper, tokio and a request codec inside the crate whose whole
character is a device and a state machine.