Skip to content

Commit 9be54e3

Browse files
lalaluneclaude
andauthored
feat(elizainference): per-op backend seam + LiteRT C-API embed backend (#35)
Generalize the M3 streaming-LLM seam to ALL on-device model ops. A shared eliza_backend::Registry<F> (backend-registry.h) holds the resolution logic (ELIZA_<MOD>_BACKEND/ELIZA_BACKEND hard-select -> highest preference_rank among available()+can_serve() -> nullptr=ggml); each modality adds a tiny factory interface + selector + one FFI chokepoint. Wired for embed/vision/asr/tts/eot: each routes to a backend that ships <bundle>/<modality>/* when present, else falls through to the in-tree ggml path. Inert-by-default (no backend registered => select() returns nullptr => every op byte-identical to before). First real backend: LiteRT text embedding (backends/litert-embed-backend.cpp, gated ELIZA_ENABLE_LITERT) on the LiteRT Next *C* API (the C++ cc/ wrappers are not standalone): env/model/compiled-model lifecycle + NPU->GPU->CPU accelerator ladder (rank 100/20/0) + reads the in-graph-pooled [1,384] output; the WordPiece tokenizer + tensor binding are the one model-specific step (MANIFEST-gated). Serves <bundle>/embedding/*.tflite; auto-promotes to NPU on Pixel-10/G5 or Qualcomm/MediaTek silicon, GPU-delegate (Mali) on a Tensor-G4. Split the LiteRT gates: ELIZA_ENABLE_LITERT = the LiteRT C-API per-op backends (embed); new ELIZA_ENABLE_LITERT_LM = the streaming-LLM backend on the heavier LiteRT-LM Engine SDK (off until that SDK is built). SESSION-OPS-TODO.md documents the vad/wakeword/speaker/diariz extension. Verified: 11/11 TUs compile (inert selectors + self-contained headers + the gated embed backend against the LiteRT SDK); adversarial review confirms inert-by-default + correct chokepoints across all 5 modalities. Co-authored-by: claude <noreply@anthropic.com>
1 parent c849143 commit 9be54e3

16 files changed

Lines changed: 1177 additions & 7 deletions

tools/omnivoice/CMakeLists.txt

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,14 @@ set(OMNIVOICE_FFI_SOURCES
8383
# backend below registers itself, so the default build keeps the in-tree
8484
# llama.cpp path.
8585
src/llm-backend-selector.cpp
86+
# Per-op backend seams (cutover M3+). Each modality's selector reuses the
87+
# shared eliza_backend::Registry (backend-registry.h) and is inert until a
88+
# gated backend registers — so the default build keeps the ggml path per-op.
89+
src/embed-backend-selector.cpp
90+
src/vision-backend-selector.cpp
91+
src/asr-backend-selector.cpp
92+
src/tts-backend-selector.cpp
93+
src/eot-backend-selector.cpp
8694
)
8795

8896
# Vendored standalone voice-classifier forward graphs (pure scalar C, no
@@ -231,7 +239,12 @@ option(ELIZA_ENABLE_VISION "Build the fused mmproj vision-describe ABI (v9)" ON)
231239
# pipe keeps the in-tree llama.cpp path. ON requires the LiteRT-LM SDK
232240
# (ELIZA_LITERT_SDK_DIR) — a host/device cross-build concern, not the Linux CI
233241
# default. See docs/multi-backend-ffi-seam.md.
234-
option(ELIZA_ENABLE_LITERT "Build the LiteRT-LM in-process LLM backend (M4)" OFF)
242+
option(ELIZA_ENABLE_LITERT "Build the LiteRT C-API per-op backends, e.g. embed (M4)" OFF)
243+
244+
# ELIZA_ENABLE_LITERT_LM — the streaming-LLM backend on the heavier LiteRT-LM
245+
# Engine SDK (litert::lm), separate from the LiteRT C runtime above. OFF until
246+
# that SDK is built; point -DELIZA_LITERT_LM_SDK_DIR / -DELIZA_LITERT_LM_LIBS at it.
247+
option(ELIZA_ENABLE_LITERT_LM "Build the LiteRT-LM in-process streaming-LLM backend" OFF)
235248

236249
# ELIZA_ENABLE_MLX — compile the CoreML/MLX in-process streaming-LLM backend
237250
# (cutover plan M5 — Apple Silicon). OFF by default; ON is Apple-only and
@@ -297,12 +310,13 @@ if(TARGET mtmd)
297310
# out, and the streaming-LLM pipe keeps the in-tree llama.cpp path — so the
298311
# default desktop/CI build is byte-for-byte the pre-seam behavior.
299312
if(ELIZA_ENABLE_LITERT)
313+
# LiteRT C-API per-op backends (embed today; vision/etc. as artifacts
314+
# ship). SDK = the LiteRT C runtime (github.com/google-ai-edge/LiteRT,
315+
# libLiteRt.so + the GPU/NPU delegate). Point at a built SDK with
316+
# -DELIZA_LITERT_SDK_DIR=<dir> and link with -DELIZA_LITERT_LIBS=LiteRt.
300317
target_sources(elizainference PRIVATE
301-
${CMAKE_CURRENT_SOURCE_DIR}/src/backends/litert-backend.cpp)
318+
${CMAKE_CURRENT_SOURCE_DIR}/src/backends/litert-embed-backend.cpp)
302319
target_compile_definitions(elizainference PRIVATE ELIZA_ENABLE_LITERT)
303-
# LiteRT-LM SDK (github.com/google-ai-edge/LiteRT-LM). Point at a built
304-
# SDK with -DELIZA_LITERT_SDK_DIR=<dir>; the device/host cross-build
305-
# links its libs + the NPU delegates with -DELIZA_LITERT_LIBS=<libs>.
306320
if(ELIZA_LITERT_SDK_DIR)
307321
target_include_directories(elizainference PRIVATE ${ELIZA_LITERT_SDK_DIR}/include)
308322
target_link_directories(elizainference PRIVATE ${ELIZA_LITERT_SDK_DIR}/lib)
@@ -311,6 +325,22 @@ if(TARGET mtmd)
311325
target_link_libraries(elizainference PRIVATE ${ELIZA_LITERT_LIBS})
312326
endif()
313327
endif()
328+
if(ELIZA_ENABLE_LITERT_LM)
329+
# The streaming-LLM backend needs the heavier LiteRT-LM Engine SDK
330+
# (litert::lm, github.com/google-ai-edge/LiteRT-LM) — separate from the
331+
# LiteRT C runtime above. Point at it with -DELIZA_LITERT_LM_SDK_DIR /
332+
# -DELIZA_LITERT_LM_LIBS.
333+
target_sources(elizainference PRIVATE
334+
${CMAKE_CURRENT_SOURCE_DIR}/src/backends/litert-backend.cpp)
335+
target_compile_definitions(elizainference PRIVATE ELIZA_ENABLE_LITERT_LM)
336+
if(ELIZA_LITERT_LM_SDK_DIR)
337+
target_include_directories(elizainference PRIVATE ${ELIZA_LITERT_LM_SDK_DIR}/include)
338+
target_link_directories(elizainference PRIVATE ${ELIZA_LITERT_LM_SDK_DIR}/lib)
339+
endif()
340+
if(ELIZA_LITERT_LM_LIBS)
341+
target_link_libraries(elizainference PRIVATE ${ELIZA_LITERT_LM_LIBS})
342+
endif()
343+
endif()
314344
if(ELIZA_ENABLE_MLX)
315345
if(NOT APPLE)
316346
message(FATAL_ERROR
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
# Session-op backend seam — design (NOT implemented)
2+
3+
The per-op backend seam (`backend-registry.h` + `<mod>-backend.h` +
4+
`<mod>-backend-selector.cpp` + a chokepoint at the top of the FFI fn) is now in
5+
place for the **one-shot** ops:
6+
7+
| modality | FFI fn | header / selector | env key | artifact dir |
8+
|----------|---------------------------------|------------------------------|-----------------------|--------------------|
9+
| embed | `eliza_inference_embed` | `embed-backend.*` | `ELIZA_EMBED_BACKEND` | `<bundle>/embedding/` |
10+
| vision | `eliza_inference_describe_image`| `vision-backend.*` | `ELIZA_VISION_BACKEND`| `<bundle>/vision/` |
11+
| asr | `eliza_inference_asr_transcribe`| `asr-backend.*` | `ELIZA_ASR_BACKEND` | `<bundle>/asr/` |
12+
| tts | `eliza_inference_tts_synthesize`| `tts-backend.*` | `ELIZA_TTS_BACKEND` | `<bundle>/tts/` |
13+
| eot | `eliza_inference_llm_eot_score` | `eot-backend.*` | `ELIZA_EOT_BACKEND` | `<bundle>/eot/` |
14+
15+
A one-shot op is stateless across calls: select → (delegate | fall through to
16+
ggml) on every call. There is nothing to keep alive between calls, so the seam
17+
is a single chokepoint at the top of the fn.
18+
19+
The **session** ops are different: `vad`, `wakeword`, `speaker`, `diariz` each
20+
`_open` a native handle (`EliVad *`, `EliWakeword *`, `EliSpeaker *`,
21+
`EliDiariz *`) that persists across many `_segment`/`_detect`/`_embed` calls and
22+
is torn down with `_close`/`_reset`. The seam has to follow that lifecycle, not
23+
re-select per call. This file records HOW to extend the seam to them. **None of
24+
the below is implemented yet.**
25+
26+
## The shape of a session op (today, in-tree only)
27+
28+
Each session modality exposes, e.g. for VAD:
29+
30+
```c
31+
EliVad * eliza_inference_vad_open(EliInferenceContext * ctx, /* params */, char ** out_error);
32+
int eliza_inference_vad_segment(EliVad * vad, const float * pcm, size_t n, /* out */, char ** out_error);
33+
int eliza_inference_vad_reset(EliVad * vad, char ** out_error);
34+
void eliza_inference_vad_close(EliVad * vad);
35+
```
36+
37+
`EliVad` (and the wakeword/speaker/diariz equivalents) is the in-tree handle
38+
struct defined in `eliza-inference-ffi.cpp`. Its in-tree fields stay exactly as
39+
they are; the seam is **additive** — one extra pointer.
40+
41+
## Extending the seam to a session op
42+
43+
For each session modality `<mod>` (vad | wakeword | speaker | diariz):
44+
45+
### 1. A session factory interface — `<mod>-backend.h`
46+
47+
Mirror the one-shot factory's four common probes, but the forward methods mirror
48+
the **session** ABI 1:1 instead of a single one-shot fn. The factory does NOT
49+
own the handle struct; it produces and operates on an opaque backend-session:
50+
51+
```cpp
52+
struct VadBackendFactory {
53+
virtual ~VadBackendFactory() = default;
54+
virtual const char * name() const = 0;
55+
virtual bool available() const = 0;
56+
virtual bool can_serve(const char * bundle_dir) const = 0; // probes <bundle>/vad/
57+
virtual int preference_rank() const { return 0; }
58+
59+
// Lifecycle, mirroring the FFI session ABI 1:1. The factory returns an
60+
// opaque backend-session pointer it owns; the FFI stashes it on the Eli*
61+
// handle. A NULL return + *out_error is a hard open failure.
62+
virtual void * open(EliInferenceContext * ctx, /* same params as eliza_inference_vad_open */,
63+
char ** out_error) = 0;
64+
virtual int segment(void * session, const float * pcm, size_t n, /* out */, char ** out_error) = 0;
65+
virtual int reset(void * session, char ** out_error) = 0;
66+
virtual void close(void * session) = 0;
67+
};
68+
```
69+
70+
Plus the same free-functions as the one-shot seam:
71+
`vad_backend_register`, `vad_backend_register_builtins` (EMPTY for now — no
72+
LiteRT session backend exists), `vad_backend_select(bundle_dir, out_error)`,
73+
backed by a `eliza_backend::Registry<VadBackendFactory>` in
74+
`<mod>-backend-selector.cpp` with env keys `ELIZA_VAD_BACKEND``ELIZA_BACKEND`
75+
and modality `"vad"`. Artifact probe dir `<bundle>/vad/` (resp. `wakeword/`,
76+
`speaker/`, `diariz/`).
77+
78+
### 2. A backend-session pointer on the Eli* handle
79+
80+
The selection happens ONCE, at `_open`, not per call. Add one field to the
81+
in-tree handle struct:
82+
83+
```cpp
84+
struct EliVad {
85+
/* ... existing in-tree fields, unchanged ... */
86+
87+
/* Backend seam (additive). When non-null, this handle is served by an
88+
* accelerator backend and every op delegates to it; the in-tree fields
89+
* above are then unused. When null, the in-tree ggml path owns the handle. */
90+
VadBackendFactory * be = nullptr; // the factory that opened be_session
91+
void * be_session = nullptr; // factory-owned backend session
92+
};
93+
```
94+
95+
### 3. Select at `_open`
96+
97+
In `eliza_inference_vad_open`, after the existing arg validation and before the
98+
in-tree handle is built:
99+
100+
```cpp
101+
char * be_error = nullptr;
102+
VadBackendFactory * be = vad_backend_select(llm_backend_context_bundle_dir(ctx), &be_error);
103+
if (be_error) { eliza_set_error(out_error, std::string(be_error)); std::free(be_error);
104+
return /* NULL handle */; }
105+
if (be) {
106+
void * sess = be->open(ctx, /* params */, out_error);
107+
if (!sess) return /* NULL handle — open failed, out_error already set */;
108+
EliVad * h = new EliVad();
109+
h->be = be;
110+
h->be_session = sess;
111+
return h;
112+
}
113+
/* else: fall through and build the in-tree handle exactly as today. */
114+
```
115+
116+
### 4. A guard at the TOP of each `_segment` / `_reset` / `_close`
117+
118+
Each per-call op checks the backend pointer and delegates before touching any
119+
in-tree state:
120+
121+
```cpp
122+
int eliza_inference_vad_segment(EliVad * vad, const float * pcm, size_t n, /* out */, char ** out_error) {
123+
if (!vad) { /* invalid-arg as today */ }
124+
if (vad->be) { // <-- guard
125+
return vad->be->segment(vad->be_session, pcm, n, /* out */, out_error);
126+
}
127+
/* ... existing in-tree ggml segment body, unchanged ... */
128+
}
129+
130+
void eliza_inference_vad_close(EliVad * vad) {
131+
if (!vad) return;
132+
if (vad->be) { vad->be->close(vad->be_session); delete vad; return; } // <-- guard
133+
/* ... existing in-tree teardown, then delete vad ... */
134+
}
135+
```
136+
137+
`_reset` follows the same guard pattern.
138+
139+
## Why this shape (vs. re-selecting per call)
140+
141+
- **Selection is per-session, not per-call.** A session's backend is fixed at
142+
`_open`; you cannot have `_segment` cross from the ggml path to LiteRT mid
143+
session because the KV/feature state lives in the (in-tree OR backend)
144+
session, not on the FFI boundary. The one pointer captures that binding.
145+
- **Hard-fail localizes to `_open`.** A bundle-invalid override surfaces once,
146+
where the caller is already prepared to handle a NULL handle, instead of on
147+
every `_segment`.
148+
- **Additive + inert.** With no session backend registered (the case today),
149+
`_open`'s `select()` returns nullptr, `be`/`be_session` stay null, and every
150+
guard is a no-op — the in-tree path is byte-for-byte unchanged. Same inert-by
151+
-default contract as the one-shot seam.
152+
153+
## Status
154+
155+
- One-shot seam: embed (with a LiteRT builtin), vision/asr/tts/eot (inert,
156+
no builtin) — **done**.
157+
- Session seam (vad/wakeword/speaker/diariz): **not implemented.** No
158+
`<mod>-backend.{h,cpp}`, no handle field, no `_open` select, no per-call
159+
guards exist yet. This file is the spec for when a session backend lands.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*
2+
* asr-backend-selector.cpp — registry + selection for the per-op ASR backend
3+
* seam. A thin instantiation of eliza_backend::Registry<AsrBackendFactory>
4+
* (backend-registry.h) — the resolution logic is shared with every other
5+
* modality. Inert by default: no -DELIZA_ENABLE_* ASR backend is compiled in
6+
* (none exists yet), so nothing registers and asr_backend_select() returns
7+
* nullptr, so eliza_inference_asr_transcribe keeps the in-tree ggml path.
8+
*/
9+
10+
#include "asr-backend.h"
11+
#include "backend-registry.h"
12+
13+
#include <mutex>
14+
15+
namespace {
16+
eliza_backend::Registry<AsrBackendFactory> g_registry;
17+
std::once_flag g_builtins_once;
18+
} // namespace
19+
20+
void asr_backend_register(AsrBackendFactory * factory) {
21+
g_registry.register_factory(factory);
22+
}
23+
24+
void asr_backend_register_builtins() {
25+
std::call_once(g_builtins_once, []() {
26+
/* No ASR backend exists yet — the seam stays inert. */
27+
});
28+
}
29+
30+
AsrBackendFactory * asr_backend_select(const char * bundle_dir, char ** out_error) {
31+
asr_backend_register_builtins();
32+
return g_registry.select("ELIZA_ASR_BACKEND", "ELIZA_BACKEND", "asr",
33+
bundle_dir, out_error);
34+
}

tools/omnivoice/src/asr-backend.h

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
#pragma once
2+
/*
3+
* asr-backend.h — per-op backend seam for speech-to-text transcription.
4+
*
5+
* A one-shot op (eliza_inference_asr_transcribe) that an accelerator backend can
6+
* serve when it ships an ASR artifact under `<bundle>/asr/`, while every other
7+
* op — and ASR itself when no artifact is present — stays on the in-tree ggml
8+
* path.
9+
*
10+
* The factory mirrors the FFI 1:1 and the FFI delegates without translation.
11+
* Selection reuses the shared eliza_backend::Registry (backend-registry.h):
12+
* ELIZA_ASR_BACKEND (per-op) then ELIZA_BACKEND (global) hard-select, else the
13+
* highest preference_rank among available()+can_serve() factories, else nullptr
14+
* (the ggml ASR path).
15+
*/
16+
17+
#include "eliza-inference-ffi.h" /* EliInferenceContext fwd, ELIZA_* codes */
18+
19+
#include <cstddef>
20+
21+
struct EliInferenceContext;
22+
23+
/* One factory per linked-in ASR runtime (e.g. LiteRT). */
24+
struct AsrBackendFactory {
25+
virtual ~AsrBackendFactory() = default;
26+
27+
/* Stable lower-case id, e.g. "litert". Matched case-insensitively against
28+
* ELIZA_ASR_BACKEND / ELIZA_BACKEND. */
29+
virtual const char * name() const = 0;
30+
31+
/* Compiled in AND host deps present (the runtime + a GPU/NPU delegate).
32+
* Cheap — must not load a model. */
33+
virtual bool available() const = 0;
34+
35+
/* The ASR artifact exists under `<bundle_dir>/asr/`. Cheap directory probe,
36+
* no model load. */
37+
virtual bool can_serve(const char * bundle_dir) const = 0;
38+
39+
/* Platform-affinity rank (higher wins; the ggml path is the implicit rank 0).
40+
* An NPU-served ASR returns a high positive value; a GPU-delegate fallback a
41+
* lower positive value. */
42+
virtual int preference_rank() const { return 0; }
43+
44+
/* Mirrors eliza_inference_asr_transcribe 1:1. Returns the number of bytes
45+
* written (excluding the terminator) on success, or a negative ELIZA_* code
46+
* with `*out_error` heap-allocated for the caller to free. */
47+
virtual int asr_transcribe(EliInferenceContext * ctx, const float * pcm, size_t n_samples,
48+
int sample_rate_hz, char * out_text, size_t max_text_bytes,
49+
char ** out_error) = 0;
50+
};
51+
52+
/* Register a factory (idempotent by name). */
53+
void asr_backend_register(AsrBackendFactory * factory);
54+
55+
/* Register every ASR backend compiled into THIS build (gated by the
56+
* -DELIZA_ENABLE_* options). Idempotent; called by asr_backend_select. */
57+
void asr_backend_register_builtins();
58+
59+
/* Pick an ASR backend for the bundle at `bundle_dir`. nullptr + no error
60+
* => use the in-tree ggml ASR path. nullptr + *out_error => hard failure. */
61+
AsrBackendFactory * asr_backend_select(const char * bundle_dir, char ** out_error);

0 commit comments

Comments
 (0)