For developers
Put Verdure inside your own app.
Speech to text, and text to a filled form, on your user’s own device with no internet. This page is the whole story: what the runtime does, the code you write, how it chooses a model, and the numbers we measured, including the ones that are not good enough yet.
What it is
Verdure is a runtime a company embeds in its own app. On the user’s device, with no internet, it does three things:
- Speech to text. Microphone audio in, transcript events out.
- Text to a filled form. You give it free text and a JSON Schema you wrote; it returns JSON that matches the schema, or an error that says exactly which fields were wrong.
- Search over documents already on the device. You index documents; you ask a question; you get the best passages back.
The company keeps its own app, screens and cloud. Verdure is the engine underneath. It never opens a network connection except to download model files from a server the company configured, and only when the company’s policy allows that.
It is written in Rust and ships as one library with whisper.cpp and llama.cpp compiled in, with bindings for Kotlin, Python and Rust today. The licence is a signed token verified offline. The models come from a signed manifest the company writes: Verdure never adds a model that is not listed.
How it works
The flow your code drives, in order:
- start — verify the licence token, read the device: tier, free memory, free disk, cores. No network.
- loadManifest — trust the signed list of bundles. A bundle is one set of model files for one task on certain device tiers.
- recommend — the bundle this device can run now, for live speech. Everything passed over is explained in a sentence: not enough memory, not enough disk, too slow to keep pace.
- download — every file, resumable, verified by SHA-256, shared between bundles that use the same model.
- load — the engines into memory. Refused with the numbers if memory is short.
- transcribe — push 16 kHz audio, get transcript events.
- extract — the transcript plus your schema in, valid JSON out. The model only ever sees an outline of the schema and the rule “never invent numbers, names or dates”; the output is validated against the real schema, and one repair round is attempted before an error is returned.
Hello world
The same flow in each language. The Kotlin one is what an Android app calls; it compiled and ran unchanged when we swapped a new model under a test integration, because the app never names a model.
import uniffi.verdure.*
val sdk = Verdure.start(token, PolicyConfig(networkDownloads = true, allowedHosts = emptyList(),
localCounters = false, encryptedStorage = true, retentionDays = null),
filesDir.resolve("verdure").absolutePath)
sdk.loadManifest(manifestJson, signature, publicKey)
val bundle = sdk.recommend(Task.SCRIBE) // what this phone can run live; bundle.skipped says why not the rest
if (!sdk.isDownloaded(bundle.id)) sdk.download(bundle.id, listener)
sdk.load(bundle.id)
val stream = sdk.transcribe(AsrConfig(langHints = listOf("en"), keyterms = listOf(), replacements = listOf()))
for (ev in stream.pushPcm(floats, 16000u)) // 16 kHz mono from AudioRecord, as floats in [-1, 1]
if (ev is TranscriptEvent.Final) show(ev.text)
val facts = sdk.extract(transcript, schemaJson).json // JSON that validates against YOUR schemaLeave keyterms empty: a 40-word clinical list made recognition worse in our measurements. Use replacements for spellings you need fixed. Every call blocks; keep them off the main thread.
The API
Every method on Verdure. Kotlin spelling shown; Rust and Python use snake_case.
| Method | Does | Fails with |
|---|---|---|
start(token, policy, dataDir) | Verify the licence offline, read the device. No network, instant. | License |
capabilities() | Tier, free memory and disk, cores, GPU class, engines built, model attributions, notes. | — |
loadManifest(json, signature, publicKey) | Trust a signed manifest you ship inside the app — the fully offline path. | Integrity |
setBundleSource(url, publicKey) | Fetch and verify a signed manifest from your host instead. | Policy, Network, Integrity |
recommend(task) | The bundle that fits this device now and keeps pace with live speech where we measured it. Says what it skipped and why. | NoBundle |
recommendFor(task, pace) | The same with the pace stated: Pace.RECORDED allows a slower, better bundle for audio already captured. | NoBundle |
fit(bundleId) | Judge a bundle you chose yourself: fits, plus every problem as a sentence. Nothing is substituted. | NoBundle |
fitFor(bundleId, pace) | The same for the pace stated. | NoBundle |
download(bundleId, listener) | Download or resume every file, verified by hash, reusing any model another bundle already holds. Bytes and one sentence per file to the listener. | License, Policy, Network, Integrity, Cancelled |
isDownloaded(bundleId) | All files present and their hashes match. | — |
load(bundleId) | Load the engines. Refuses with the numbers when memory is short. 1.2 s for the base bundle, 15 s for the small one on a Pixel 8a. | Memory, EngineUnavailable, Engine |
loadOvercommit(bundleId) | load without the memory gate, for a device you have measured yourself. | as load |
transcribe(config) | Open a stream: pushPcm(samples, rate) returns transcript events; finish() flushes. | EngineUnavailable |
extract(text, schemaJson) | JSON valid against your schema, or an error naming each field that was wrong. Blocks for the whole generation. | EngineUnavailable, InvalidSchema, SchemaInvalid |
chat(messages, sink) | One conversation turn, streamed token by token. | EngineUnavailable, Engine |
index(collection, docs) | Chunk and index documents already on the device. | Storage |
query(collection, question, k) | The best k passages. | Storage |
delete(scope) | A collection, the indexes, or everything — the encryption key first, then the files. | Storage |
setCloudFallback(hook) | Give Verdure your cloud for when no engine is loaded. Verdure never opens that socket itself. | — |
Errors are typed and their messages are sentences for your log: SchemaInvalid lists each violation by JSON path; NoBundle says what did not fit and why. Show your user a sentence of your own.
Which model, which device
Verdure sorts devices into six tiers by what they can honestly hold, and a bundle declares which tiers it is built for. The device has the last word: recommend reads free memory right now, not a spec sheet.
| Tier | Device | For example |
|---|---|---|
| P1 | 4 GB phone | Itel A70, Tecno Spark Go |
| P2 | 6 GB phone | Redmi 13C, Galaxy A16 |
| P3 | 8 GB phone | Pixel 8a, iPhone 12 and later |
| L1 | 4 GB laptop | ThinkPad T460 |
| L2 | 8 GB laptop | 2019 Intel MacBook, a school-lab PC |
| L3 | 16 GB+ laptop | Apple Silicon, or a discrete GPU |
Memory is a rule, not a guess: a language model needs its file size times 1.5 plus 400 MB, the speech model its size plus 100 MB. Speed is a measurement the manifest carries per tier. For live speech, a bundle slower than 1.5× real time on this tier is passed over with the number as the reason; a tier we have not measured is not judged. For audio that is already recorded, recommendFor(task, Pace.RECORDED) allows the slower, better bundle.
You can always pin a bundle yourself: fit(id) tells you whether it fits that device and at what speed, and loadOvercommit exists for a device you have measured yourself.
What we measured
Ten real two-speaker GP consultations (PriMock57, 12,000 reference words), scored on word error rate and on a list of a hundred clinical terms, run through the SDK’s own transcribe() on an 8 GB Intel Mac and on a Pixel 8a inside a test Android app. Speed is processing time per second of speech, fed flat out; the phone figure is the sustained, throttled one.
| Speech model | Bundle | Download | Word error | Clinical terms missed | Speed, Intel Mac | Speed, Pixel 8a |
|---|---|---|---|---|---|---|
| Whisper base | scribe-floor | 148 MB | 26.5 % | 1 in 3.6 | 0.28× | 1.25× |
| Whisper small | scribe-small | 488 MB | 21.6 % | 1 in 6 | 0.75× | 3.2× |
- Quality travels. The phone produced the same transcript quality as the laptop, file for file, with the same words missed.
- Memory on the Pixel 8a: the base bundle 683 MB idle and 952 MB peak; the small bundle 635 MB idle and 1,143 MB peak. No out-of-memory kill in 79 minutes of audio.
- Download and load: 979 MB in 54 s over Wi-Fi, verified; load 15 s (small) and 1.2 s (base). Two bundles that share a model download it once.
- Keyterms hurt. Feeding a 40-word vocabulary list to the recogniser doubled the error rate. It is documented as experimental and off by default.
- A medical fine-tune was worse. A clinical Whisper medium fine-tuned on African-accented read speech scored 83 % word error on these consultations as converted. We do not ship it.
Full tables and method in the repository’s TEST_REPORT.md and knowledge/26-sdk.md; they come with the SDK.
Privacy and licences
What leaves the device: nothing, except the model files coming in, from a host you name in the policy, over https only. No telemetry, no accounts, no counters unless you turn local counters on for yourself. Storage is encrypted by default and delete(All) destroys the key before the files. Verdure holds no user data on any server, so it is not a processor of it.
The licence token is verified offline against a public key; a token names the vendor, the seats, the bundles and the model licences the vendor has acknowledged. Those model licences are listed, not discovered:
| Component | Licence | From |
|---|---|---|
| Whisper (base, small) weights | MIT | OpenAI |
| whisper.cpp, llama.cpp (the engines) | MIT | ggml |
| Qwen2.5 0.5B and 1.5B Instruct | Apache-2.0 | Alibaba Qwen |
All permissive and shippable in a commercial product. A bundle whose model needs an acknowledgement (Llama, Gemma terms) is refused until the token carries it; attribution strings a licence requires are handed to your app by capabilities().
What is not done yet
- Extraction on the smallest language model is not good enough. The 0.5B model returned schema-valid facts on 3 of 10 one-minute windows on the phone; the rest failed validation. The 1.5B bundle is the next measurement. Until then, extraction on a phone is a demonstration, not a product.
- Speed is measured on two devices. A Pixel 8a and an 8 GB Intel Mac. Other tiers are not judged for speed until we measure them.
- Windows are fixed at 12 seconds. Sentences get cut; voice-activity windowing is next.
- Grammar-constrained decoding is built but off, after the engine aborted on a multi-byte token in an earlier app. Validation plus one repair round stands in for it.
- Bindings: Kotlin, Python and Rust today. Swift and a C header are generated by the same tool and not yet packaged.
Get the test kit
Write to us and say which platform you build for. You receive the Android zip (library, Kotlin binding, a ten-year sandbox licence, a signed example manifest with real models, the guide and a changelog) or the repository for Rust and Python, and a person who answers questions the same day.