A smart-home voice assistant for Persian speakers — wake word on a 1 GB edge device, speech recognition in the cloud, and a per-user intent model small enough to fit in a 40 KB file.
Reference architecture · designed, implemented and validated as a working prototype · 2024–2025 · Level 1–2 (skeleton)
In two minutes
Two notes on timing, because the difficulty of an AI project is only legible against what was available when it was built. This one started in late 2024 and evolved over roughly a year. At that point open-weight multilingual models — particularly the small and mid-sized ones this design depends on — were rare, and for Persian, largely absent. Much of what follows would be a procurement exercise today. It was not one then.
The system has two halves: cloud AI services and an edge client running on the household hub.
The edge client
The client runs on a Raspberry Pi 3 with 1 GB of RAM. It listens continuously, and when the wake word fires it records the utterance, gets the user’s intent recognized, and hands the resulting action to whatever process is responsible for executing it.
The Pi cannot hold a speech model, so the edge’s job is gating, not understanding: wake word, then voice-activity detection to find the end of the utterance. Only the gated clip crosses the network.
That gating loop is where the latency budget was actually won. Recording and silence-watching run concurrently, so the clip is posted the instant the speaker stops rather than after a fixed timeout. Buying the same second inside the ML stack would have cost far more complexity for far less return — the cheapest time in this system was bought in control flow, not in a model.
The clip is posted to a cloud API together with the user’s key. The cloud maps it to one of that household’s commands and returns an action identifier. The hub publishes that identifier on D-Bus, the message bus already present in the Linux system, and any local service listening on the bus acts on it. Adding a new controllable device means subscribing to a bus signal, not modifying the assistant.
The part worth stealing
The obvious design calls a large model on every utterance to parse intent. This one calls it once per user, at setup.
The household gives one example sentence per command. A large general-purpose model is prompted — exactly once — to generate Persian sentences that could plausibly express the same intent. Those become a training set. A ParsBERT encoder produces the embeddings and a single fully-connected layer is trained on top of them; the result is that household’s own classifier, under 40 KB, stored against their user key. Every utterance from then on is answered by that classifier alone, in a fraction of a second, on a CPU.
The intelligence is paid for once and amortized forever. The reasoning and the price of that choice are on the decision page; the mechanism, including the part that handles sentences that are not commands at all, is on the architecture page.
Measured:
| End-to-end response | 2.85 s average, 1.25 s standard deviation |
| Command recognition | 96 % |
| Speech recognition throughput | < 0.5 s compute per second of audio |
| Per-user intent model | < 40 KB |
| Examples needed per command | 1 |
Measured from the moment the clip is posted to the moment the hub has the action identifier, on a connection with no significant latency. Conditions and sample sizes are stated in full on the result page — including how few speakers this was tested with.
The story in ten minutes
Read in order, or jump.
- The problem — why Persian, why this was not an integration exercise
- The architecture — three layers, four services, and where the intelligence sits
- The decision — spending the large model at training time instead of inference time, and what that cost
- The result — what it did, how fast, and what it did not do
Going deeper
- Appendix · Level 5 — interface surface, glossary, source material
1 · The Problem
What was asked for
Voice control for a smart-home product line, in Persian. A household should be able to speak a command and have the house respond — the ordinary expectation set by every English-language assistant on the market.
Who wanted it, and what they were really buying
The assistant was not a standalone product. It was one workstream inside a larger IoT and smart-home programme run by the digital arm of a large industrial group, and it sat at the point where that programme met the customer.
The intended journey was concrete. A household buys smart equipment from the company and is given a profile on the company’s platform. One of the things they buy is the hub — the box the assistant lives on. On their profile they set up their own commands, and on the hub they choose which actions each command maps to. From then on the client app and the cloud services use that household’s own trained classifier to turn speech into those actions.
So there were two things being bought at once, and they pulled in different directions:
- A product feature. The hub needed to talk, and it needed to talk in Persian.
- An institutional position. Being an organization that owns Persian speech and intent services rather than renting them. This is why the cloud side was specified as general-purpose services rather than as one assistant — a decision taken up on the decision page.
It was also a coordination problem. The programme ran to roughly thirty people across UI/UX, front end, back end, DevOps and electronics, and voice touched nearly all of them: the profile UI where commands are defined, the back end that holds user keys and trained models, DevOps for the containers and their addressing, and electronics for the microphone, the hub and the things it switches. A meaningful share of the design work was negotiating those boundaries rather than choosing models. The interface shapes in the appendix are the residue of those negotiations.
Why it was not an integration exercise
In English this is procurement. You call a speech API, call an intent API, and spend your effort on the product. Here neither of those existed in a form you could build a product on, so the recognition layer had to be built rather than bought. That changes what kind of project this is: not “wire up an assistant” but “build the missing infrastructure, then wire up an assistant.”
Five constraints shaped everything that followed.
The usual suppliers were not available. Managed speech APIs, hosted intent platforms, smart-home clouds and a good deal of the deployment infrastructure underneath them are not offered to developers working from Iran. That is a fact about market access rather than about engineering, and its practical effect was simple: the parts of the stack that would ordinarily be bought had to be designed. It also set a standing preference that runs through every page here — depend on as little outside the building as possible — and the one place we could not avoid an external dependency is exactly where the measurements later got ugly. See the decision page.
Persian tooling was thin. No serviceable cloud speech-to-text for Persian, and no Persian natural-language-understanding service at all. Open-weight multilingual models were rare in 2024, and the small-to-mid-sized ones this design depends on were rarer still. Whatever existed for English — managed intent platforms, hosted wake-word services — had no counterpart.
The hardware could not hold the models. The hub was a Raspberry Pi 3: four ARM cores, 1 GB of RAM. That is enough to listen continuously and decide whether someone is speaking. It is not enough to run a speech recognition model. The split between edge and cloud was not a preference; the hardware drew the line and the architecture had to respect it.
Every household speaks differently. “Turn the lights off,” “I don’t want light,” “it’s too bright” — the same intent, three unrelated sentences. A fixed command vocabulary fails immediately in a home, and a household cannot be asked to label hundreds of examples. Personalization had to be nearly free for the user.
It had to feel like a conversation. The team settled on four seconds as the outside limit of what a household would accept between finishing a sentence and seeing the house respond. Past that, people repeat themselves, and then they stop using it. The measured 2.85 s meets that target — but only because silence detection runs concurrently with the recorder, so the clip is posted the moment the speaker stops rather than after a fixed timeout. Without that, the budget was not reachable.
What made it hard, in one line
The interesting constraint was not the language. It was that the language forced us to build the intelligence, and the hardware forbade us from putting it where it was needed.
2 · The Architecture
The shape
Three layers, and the boundaries between them are the design. Each of the three gets a section below; the fourth section is the one the picture deliberately leaves out.
The edge does gating, not understanding
The Pi runs exactly one always-on model: wake-word detection. Everything else on the device is control flow.
Once the wake word fires, the hub acknowledges audibly and starts recording. Two things then run concurrently — a recorder that keeps appending audio, and a watcher that samples short slices and tests each for silence. When the watcher sees silence, it stops the recorder. The utterance is now bounded, and only that bounded clip leaves the house.
This is the cheapest possible edge: it never transcribes, never interprets, and holds no model larger than a wake-word detector. But it decides the two things that matter most for both cost and privacy — when audio is captured and when it stops. Continuous audio never crosses the network.
It is also where the latency target was met. Ending the utterance by detection rather than by a fixed timeout is worth roughly a second of dead air on every command, and it costs nothing but concurrency. That second would have been far more expensive to find anywhere in the ML stack — a point I make again on the result page, because it is the kind of win that never shows up in a model card.
The cloud does understanding, decomposed
Four services rather than one assistant, each independently deployable behind an HTTP interface:
| Service | Responsibility |
|---|---|
| Speech-to-Text | Persian audio in, text out. General-purpose; nothing about it knows this is a smart home. |
| Text Classifier | Trains and serves named text-classification models. Also general-purpose. |
| Model registry | Stores, loads, deletes and reports on trained models by key. The thing that makes per-user models operationally possible. |
| Speech-to-Action | The only service that knows about this product. Orchestrates the other three into “audio → this user’s action.” |
The decomposition follows one rule: a service that could be useful to another project does not get to know about this one. Speech-to-Text and the classifier are reusable infrastructure. Speech-to-Action is the assistant. Only the last one would need rewriting for a different product. Why that was worth paying for is on the decision page.
The registry is the unglamorous half of the idea
Per-user models are the product idea. A registry is what turns that from a demo into something you can operate, so it is worth saying what it actually was.
It shipped as its own container image holding a database and the trained artifacts. The weights themselves are files on disk; the database stores only the path to a file plus its metadata, addressed by user key. A thin HTTP layer in front of it lets other back-end services store a model, load one, list what is registered, and delete by key.
The design point is that nothing clever is happening. Weights stay out of the database and only the index goes in it, which keeps the registry boring — and boring is the property you want in the one service that every household’s assistant depends on. It is also what makes the natural next question answerable at all: how many models are there, whose are they, and which ones are stale?
Returning to the house over D-Bus
The cloud returns an action identifier — a short string, not a device command. The hub publishes it on D-Bus, the message bus already present in the Linux system, and any local service that has subscribed acts on it.
It is a broadcast signal, not an addressed method call. This is the piece I would defend hardest, and it has one consequence I would rather state than have found. The assistant does not control lights. It announces intent, and control belongs to whoever owns the device. Adding a new controllable thing means writing a subscriber; the assistant is not touched, redeployed or even aware. A failed cloud call degrades to nothing happens rather than to a half-executed command, because the assistant holds no device state to corrupt.
And the assistant never learns whether the action occurred. Broadcast is fire-and-forget: there is no return value, no acknowledgement, and therefore no honest way for the hub to tell a user “that didn’t work.” For a prototype whose actions are all idempotent switches, that trade is clearly right. For a product with a door lock on the bus, it is clearly not, and the fix is not subtle — it is a second signal in the opposite direction and a hub that waits for it.
Where the intelligence sits
Manifesto §13: how the software components are arranged and how the intelligence is arranged are two different architectures. The diagram above is the first one. This is the second, and it is the more distinctive of the two — it is why the large language model does not appear anywhere in that picture.
Three things in that picture are worth naming.
The encoder is frozen and the trained part is one layer. Nothing is fine-tuned. A Persian sentence encoder produces the representation, and the only parameters that belong to a household are the weights of a single fully-connected layer on top of it. That is what makes the per-user model 40 KB instead of hundreds of megabytes, why training takes seconds rather than a session on a GPU, and why inference is a fraction of a second on an ordinary CPU. The architecture is not clever; it is cheap, and cheap is what makes “one model per household” a sentence you can say without laughing.
The expensive model is upstream of training, not upstream of an answer. The LLM is called once, to turn a handful of user sentences into a training set. It is never in the request path. Everything this buys and everything it costs is the decision.
One of the labels is not a command. Alongside the household’s commands, the training
set always includes a fixed corpus of irrelevant Persian sentences of varying length,
labelled no-command-recognized. This turned out to matter more than any accuracy
number.
The thing I would tell anyone designing a user-facing AI system. As soon as the primary functionality works, users stop testing whether it works and start testing where it breaks. Some of it is accidental and some of it is entirely deliberate — people say deliberately absurd things to a new assistant to see what it does. Plan for that traffic before you ship, because it arrives on day one.
Explicitly training the “this is not a command” case, rather than thresholding it away, was the cheapest robustness in the whole system — and the response was the opposite of what you might expect. A system that answers “I did not understand that” reads as more intelligent to its user than one that guesses, not less.
3 · The Decision
Spend the large language model once, at setup — not on every utterance.
The choice
A household says the same thing many ways. “Turn the lights off,” “I don’t want light,” “it’s too bright.” Mapping that variety onto one action is the entire intent problem, and there were two ways to solve it.
The obvious one: send every transcribed utterance to a large language model and ask it which command the user meant. It is simple, it needs no training, it handles phrasings nobody anticipated, and it is what most teams would build today.
The one taken: call the large model once per user, at setup. The user gives one example sentence per action. The model expands each into many Persian phrasings that could express the same intent. Those become a training set for a small classifier belonging to that household. Every utterance from then on is answered by the classifier alone. The large model is never in the request path.
The mechanism — frozen encoder, one trained layer, 40 KB per household — is on the architecture page. This page is about why it was worth it, and what it cost.
Why
| LLM per utterance | LLM once per user | |
|---|---|---|
| Cost | Every command costs money, forever | One fixed cost per household |
| Latency | An extra model call in the critical path | Classifier inference — negligible |
| Consistency | Answers can drift between calls | Fixed decision boundary; measurable |
| Evaluation | Hard to characterize | Ordinary classification metrics |
| Privacy | Every utterance leaves for a third party | Utterances stay within our own services |
| Offline path | None | The classifier is 40 KB — it could eventually run on the hub |
The last row is the one that decided it. A 40 KB model is small enough that the long-term architecture — inference moving down onto the Pi, cloud needed only at setup — stays open. The per-utterance design closes that door permanently. Choosing it would have meant choosing, on day one, that this product can never work without a paid API and an internet connection — and, given where we were building it, without a paid API we might not be able to reach at all.
That is the shape of the argument: not “this is cheaper” but “this leaves the important future available.”
What it cost
Every real decision is a trade, and this one is not free.
Setup depends on someone else, and that is where the whole wait is. Four setup runs were recorded during evaluation:
| Commands defined | Phrasings per command | LLM response | Classifier training | Outcome |
|---|---|---|---|---|
| 5 | 15 | 2 min 30 s | 14 s | ok |
| 5 | 15 | 5 min 00 s | — | failed |
| 5 | 15 | 3 min 11 s | 12 s | ok |
| 3 | 8 | 39 s | 5 s | ok |
The split is the interesting part. Training the household’s classifier — the part this architecture is about — takes five to fourteen seconds. Waiting for the language model takes between forty seconds and five minutes, and it is essentially the entire setup time. The generator’s latency was inflated further by having to route requests around network restrictions, which is not a property of the design but was very much a property of running it.
One run in four ended with nothing. The model returned text that did not match the structure the prompt asked for; the parser could not turn it into a training set; setup failed. The shape of that failure is more instructive than its rate: nothing was wrong with the paraphrases. A free-form generator was being used as a structured data source with no contract between them and no repair path — no schema enforcement, no retry, no fallback to the user’s original sentences. In 2024 that was a normal way to build; it is not a defensible one, and it is the first thing I would fix.
The system inherited a dependency it could not control — at exactly the moment a new user meets the product, and in an environment where third-party APIs were the one class of component we had spent the whole architecture trying not to need.
Novel phrasings degrade rather than adapt. The per-utterance design would handle a
sentence nobody anticipated. This one returns a low-confidence answer, or the wrong
command, or no-command-recognized. That is a permanent property of the choice, not a bug
to be fixed.
What I got wrong
Not the decision. Moving the expensive model out of the request path is still right, and I would make it again.
What I got wrong is where I left the dependency I could not remove. The whole architecture exists to keep an external model out of the path that runs a million times — and at that it worked. Then it put the same external model in the path that runs once, at the moment a new household has invested nothing and has no reason to wait. I designed the steady state carefully and never designed the first five minutes.
The fix does not need a different architecture, only a different answer to when. The household’s own example sentences are already enough to train a usable — if brittle — classifier in seconds. Ship that, let the assistant work, and run the paraphrase expansion in the background, swapping in the better model when it arrives. A failed expansion then costs the user a weaker classifier for a while instead of a dead end.
A latency budget that only covers the steady state is not a latency budget. The paths that run once — setup, onboarding, recovery, migration — are the ones with no tolerance at all, because the user has nothing invested yet and no reason to wait.
The second decision: four services, not one assistant
Shorter, and mine to claim — I proposed the decomposition rather than receiving it.
The straightforward build is a single assistant service: audio in, action out, one deployment. It would have shipped sooner, and it would have been faster at runtime, since every boundary between services is a network hop inside a four-second budget.
What was built instead is four services governed by one rule: a service that could be useful to another project does not get to know about this one. Speech-to-Text takes Persian audio and returns text. The classifier trains and serves named models. The registry stores them. Only Speech-to-Action knows that any of this is a smart home.
The justification is not a technical one, which is why it is worth stating separately. Nothing about a monolith would have failed. The argument was organizational: general Persian speech and intent services are useful to a group that builds more than one product, and — as the problem page sets out — they could not be bought from anyone. Building them as services rather than as parts of an assistant is what makes the difference between this team shipped a feature and this organization now owns Persian speech infrastructure. That distinction is what the work was really for.
The cost was paid up front and in full. Four deployment surfaces, four things to keep running, four health checks, and hops in the latency budget that a monolith would not have spent. Reuse, meanwhile, is a promise about the future: within the life of this programme the services had exactly one consumer, so the return on that architecture had not yet arrived when the work stopped. That is not an argument against the decision, but it is the honest accounting of it — architecture bought on future reuse is a loan, and it is worth knowing who is expected to repay it.
4 · The Result
What it did
Speak a command in Persian. The house responds in under three seconds.
The full path ran end to end: audio captured on the hub, transcribed in the cloud, classified against that user’s own command set, published on D-Bus, acted on by a listening service. The speech-to-text service was containerized and served the hub. Persian intent recognition worked from a single example per command.
People other than me spoke to it. Three or four speakers, around ten utterances each. That is a small number and I will not dress it up, but it is a different kind of fact from an accuracy figure: the system was not only correct, it was correct for someone who had not built it.
Measured
| Metric | Value | Conditions |
|---|---|---|
| End-to-end response | 2.85 s avg · 1.25 s std | From the clip being posted to the hub holding the action identifier. Connection with no significant latency. |
| Command recognition accuracy | 96 % | Spoken command → correct action, so transcription errors are counted against it. Assumes usable recording quality. |
| Transcription | < 0.5 s compute per second of audio | Server-class CPU; excludes network |
| Per-user intent model | < 40 KB | One trained layer over a frozen encoder. Up to ~14 commands trained in practice. |
| Examples required per command | 1 | Expanded automatically at setup |
| Per-user setup time | ≈ 40 s – 5 min | Four recorded runs, one of which failed. Classifier training is 5–14 s of that; the rest is the third-party generator. |
Both headline numbers come from the same trials: three to four speakers, roughly ten utterances each. The records were not kept centrally, so these are stated as what the evaluation showed rather than as a reproducible benchmark.
The 1.25 s standard deviation on a 2.85 s mean is the number I find most useful. A response that varies between 1.6 s and 4.1 s is perceptibly inconsistent — a user learns to distrust the pause, and the top of that range is at the four-second limit the team had agreed on. Reporting the spread rather than only the mean is the difference between a benchmark and a claim.
Worth naming where the latency budget was actually won: not in the models, but in ending the recording by silence detection instead of by a fixed timeout. That is control flow, it cost a day, and it is worth about a second on every single command.
What it did not do
Stated plainly, because the reader will find out anyway and it costs nothing to say first.
- Not a product in households. The prototype ran end to end; the surrounding product programme was deprioritized before rollout, so it never carried real users.
- No multi-turn dialogue. One utterance, one action. No context, no follow-ups, no “and also turn on the fan.”
- No speaker identification. The system knew a user’s command set, not which household member was speaking.
- No streaming. Transcription was file-based — the utterance had to end before processing began, which is a direct contributor to the latency figure above.
- Persian only, and no evaluation across dialects or accents.
- No confirmation path. The bus was told what to do; nothing reported back whether it happened. See the D-Bus tradeoff.
- Setup was never made robust. One failed run in four was never engineered away.
Appendix — Level 5
Supporting material. Nothing here is required to understand the project.
Interface surface
Shape only, not a reference. Manifesto §10: the portfolio is not an API reference. The reason to show any of it is that the shape of an interface reveals a design stance — these do, in one specific way, noted below.
Speech-to-Text
POST /transcribe audio (mp3) ──▶ { transcription, processing_time_ms, audio_duration }
GET /health ──▶ { status, model_loaded, version }
Text Classifier
POST /train { model_name, [ {sentence, label}, … ] } ──▶ { model_name, samples }
POST /predict { model_name, [ sentence, … ] } ──▶ [ { label, confidence }, … ]
Speech-to-Action
POST /design { user_key, [ {sample_command, action}, … ] } ──▶ 201 | key_error
POST /command-recog { user_key, audio } ──▶ { action, confidence }
Model registry
store(model, metadata, key) ──▶ 201 | key_error
load(key) ──▶ model
delete([key, …]) ──▶ { msg, [key, …] }
get_report() ──▶ [ metadata, … ]
The stance worth noticing: /train and /predict are the same service, and
design and command-recog are the same service. Training is an ordinary online
operation addressed by key, not an offline pipeline someone runs. That is what makes
per-user models operationally possible, and it is the reason the model registry is not
optional.
How the registry stored a model
Worth a sentence, because it is the least glamorous and most load-bearing part of the per-user idea. The registry shipped as its own container image containing a relational database and the artifacts. Trained weights are files on disk; the database holds only the path and the metadata, keyed by user. The HTTP layer in front of it exists so that other back-end services — not people — can register, list, load and delete models.
Keeping the weights out of the database is the whole trick, and it is not a clever one. It is what keeps the service small enough to be boring, and boring is the property you want in the one component every household’s assistant depends on.
Glossary
| Term | Meaning |
|---|---|
| D-Bus | Message bus built into Linux. Processes publish signals and call methods on each other without knowing who is listening. |
| VAD | Voice activity detection. Deciding whether a slice of audio contains speech — here, used to find the end of an utterance. |
| Wake word | The always-on detector that decides when the assistant is being addressed. The only model running continuously on the edge device. |
| STT | Speech-to-text. |
| WER | Word error rate. Standard accuracy metric for transcription. Not measured in this project — noted rather than glossed over. |
| Intent | The action a user meant, independent of the words used. |
| ParsBERT | A Persian sentence encoder. Used frozen here — it produces the representation, and only a single layer on top of it is trained per household. |
no-command-recognized | A deliberate label in every household’s classifier, trained on irrelevant Persian sentences, so that “this is not a command” is an answer rather than a failure. |