Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/eliza-paradee-stream.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: Paradee streaming contract
on:
pull_request:
paths:
- 'tools/omnivoice/**'
- '.github/workflows/eliza-paradee-stream.yml'
workflow_dispatch:
jobs:
contract:
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Configure host contract doubles without models
run: >-
cmake -S . -B build-stream -G Ninja
-DCMAKE_BUILD_TYPE=Release -DGGML_NATIVE=OFF
-DLLAMA_BUILD_OMNIVOICE=ON -DLLAMA_BUILD_TESTS=ON
-DLLAMA_BUILD_SERVER=OFF -DLLAMA_CURL=OFF
-DMTMD_VIDEO=OFF
- name: Build FFI and bounded dispatch tests
run: cmake --build build-stream --target eliza-inference-test-paradee-asr eliza-inference-test-paradee-stream-output -j 2
- name: Exercise FFI chunking and cancellation
run: ctest --test-dir build-stream --output-on-failure -R 'eliza-inference-test-paradee-(asr|stream-output)$'
7 changes: 7 additions & 0 deletions tools/omnivoice/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,13 @@ if(TARGET mtmd)
endif()
endif()

add_executable(eliza-inference-test-paradee-stream-output
tests/paradee-stream-output.cpp)
target_compile_features(eliza-inference-test-paradee-stream-output PRIVATE cxx_std_17)
if(BUILD_TESTING)
add_test(NAME eliza-inference-test-paradee-stream-output COMMAND eliza-inference-test-paradee-stream-output)
endif()

add_executable(eliza-inference-test-paradee-asr
tests/ffi-paradee-asr.cpp
tests/paradee-contract/fake-paradee.cpp
Expand Down
27 changes: 24 additions & 3 deletions tools/omnivoice/include/eliza-inference-ffi.h
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* libelizainference FFI ABI v18.
* libelizainference FFI ABI v19.
*
* (Banner tracks ELIZA_INFERENCE_ABI_VERSION below; the per-version history is
* at the end of this header preamble, newest first.)
Expand Down Expand Up @@ -134,6 +134,7 @@ extern "C" {
* load and refuses to bind if they disagree.
*
* Changelog:
* v19: bounded Paradee model-chunk PCM delivery and cancellation.
* v18: text-model context capacity resolved before stream admission.
* - `eliza_inference_llm_context_capacity(ctx, requested_context,
* n_gpu_layers, &out_capacity, &err)` loads the resident text model
Expand Down Expand Up @@ -296,9 +297,9 @@ extern "C" {
* v7: real Silero VAD (same symbol surface as v6).
* v6: fused wake-word, speaker, diarizer.
*/
#define ELIZA_INFERENCE_ABI_VERSION 18
#define ELIZA_INFERENCE_ABI_VERSION 19

/* Returns a static, NUL-terminated string of the form "18" matching
/* Returns a static, NUL-terminated string of the form "19" matching
* ELIZA_INFERENCE_ABI_VERSION at the time the library was built. The
* pointer is owned by the library — do NOT free. */
const char * eliza_inference_abi_version(void);
Expand Down Expand Up @@ -674,6 +675,26 @@ int eliza_inference_paradee_synthesize_alloc(
size_t * out_samples,
char ** out_error);

/* Paradee model-chunk streaming (ABI v19). Emits each <=510-id model
* chunk before synthesizing the next, in callbacks of at most 24000 samples.
* This is not sub-model/incremental decoding. G2P runs before the first chunk.
* The synchronous callback provides backpressure; PCM is borrowed only during
* the callback. Return nonzero to cancel. cancel_tts may run on another thread.
* The callback must not reenter synthesis, loading, eviction or destruction of
* this context. No more PCM is delivered after cancellation is observed.
* Unlike the OmniVoice stream API, a zero-length is_final=1 callback occurs
* only after successful synthesis; errors/cancellation have no final callback.
* The return code is authoritative, including cancellation by the final callback.
* Already delivered PCM cannot be retracted: callers must stop their playback
* queue on any failure. Empty valid input produces only the final callback.
* No full-utterance PCM buffer is accumulated. Text/chunk metadata and a single
* model output still scale to their respective inputs; this is not an input cap.
*/
int eliza_inference_paradee_stream_supported(void);
int eliza_inference_paradee_synthesize_stream(
EliInferenceContext * ctx, const char * text, size_t text_len, float speed,
eliza_tts_chunk_cb on_chunk, void * user_data, char ** out_error);

/* Same as `paradee_synthesize_alloc`, but the input is a misaki phoneme
* string (as returned by `paradee_phonemize`); G2P is skipped. Characters
* outside the model vocab are dropped. */
Expand Down
49 changes: 44 additions & 5 deletions tools/omnivoice/src/eliza-inference-ffi.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
// resolve `eliza_inference_*` symbols from this object.

#include "eliza-inference-ffi.h"
#include "paradee-stream-output.h"
#include "llm-backend.h"
#include "embed-backend.h"
#include "vision-backend.h"
Expand Down Expand Up @@ -2360,17 +2361,19 @@ static int eliza_paradee_synthesize_owned(
float speed,
float ** out_pcm,
size_t * out_samples,
char ** out_error) {
char ** out_error,
eliza_tts_chunk_cb on_chunk = nullptr,
void * user_data = nullptr) {
const char * operation = phoneme_input
? "paradee_synthesize_phonemes_alloc"
: "paradee_synthesize_alloc";
if (!out_pcm || !out_samples) {
if (!on_chunk && (!out_pcm || !out_samples)) {
eliza_set_error(out_error,
std::string("[libelizainference] ") + operation + ": null output argument");
return ELIZA_ERR_INVALID_ARG;
}
*out_pcm = nullptr;
*out_samples = 0;
if (out_pcm) *out_pcm = nullptr;
if (out_samples) *out_samples = 0;
if (!ctx || !input) {
eliza_set_error(out_error,
std::string("[libelizainference] ") + operation + ": null argument");
Expand Down Expand Up @@ -2434,9 +2437,20 @@ static int eliza_paradee_synthesize_owned(
std::string("[libelizainference] ") + operation + ": paradee_synthesize failed: " + err);
return st == eliza_paradee::PARADEE_E_OOM ? ELIZA_ERR_OOM : ELIZA_ERR_FFI_FAULT;
}
pcm.insert(pcm.end(), audio.samples.begin(), audio.samples.end());
if (on_chunk) {
if (!eliza_paradee_stream_output(audio.samples.data(), audio.samples.size(),
on_chunk, user_data, [&]() { return ctx->paradee_cancel.load(std::memory_order_acquire); }))
return cancelled();
} else {
pcm.insert(pcm.end(), audio.samples.begin(), audio.samples.end());
}
}
if (ctx->paradee_cancel.exchange(false, std::memory_order_acq_rel)) return cancelled();
if (on_chunk) {
if (on_chunk(nullptr, 0, 1, user_data) != 0 || ctx->paradee_cancel.load(std::memory_order_acquire))
return cancelled();
return ELIZA_OK;
}
if (pcm.empty()) return ELIZA_OK;

float * owned = static_cast<float *>(std::malloc(pcm.size() * sizeof(float)));
Expand Down Expand Up @@ -2601,6 +2615,31 @@ int eliza_inference_paradee_synthesize_phonemes_alloc(
#endif
}

int eliza_inference_paradee_stream_supported(void) {
#ifdef ELIZA_ENABLE_PARADEE
return 1;
#else
return 0;
#endif
}

int eliza_inference_paradee_synthesize_stream(
EliInferenceContext * ctx, const char * text, size_t text_len, float speed,
eliza_tts_chunk_cb on_chunk, void * user_data, char ** out_error) {
#ifdef ELIZA_ENABLE_PARADEE
if (!on_chunk) {
eliza_set_error(out_error, "[libelizainference] paradee stream: null callback");
return ELIZA_ERR_INVALID_ARG;
}
return eliza_paradee_synthesize_owned(
ctx, text, text_len, false, speed, nullptr, nullptr, out_error, on_chunk, user_data);
#else
(void) ctx; (void) text; (void) text_len; (void) speed;
(void) on_chunk; (void) user_data;
ELIZA_PARADEE_NOT_BUILT(out_error);
#endif
}

void eliza_inference_free_pcm(float * pcm) {
std::free(pcm);
}
Expand Down
17 changes: 17 additions & 0 deletions tools/omnivoice/src/paradee-stream-output.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#pragma once
#include <algorithm>
#include <cstddef>

// Callback lifetime and backpressure apply to each bounded PCM window.
template<class Callback, class Cancelled>
bool eliza_paradee_stream_output(const float * pcm, size_t count,
Callback callback, void * user, Cancelled cancelled) {
constexpr size_t max_samples = 24000;
for (size_t offset = 0; offset < count;) {
if (cancelled()) return false;
const size_t size = std::min(max_samples, count - offset);
if (callback(pcm + offset, size, 0, user) != 0 || cancelled()) return false;
offset += size;
}
return !cancelled();
}
6 changes: 3 additions & 3 deletions tools/omnivoice/tests/ffi-abi.c
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* tools/omnivoice/tests/ffi-abi.c
*
* C11 test against the real libelizainference (ABI v18). Always runs:
* - ABI string "18" and the v17 capability probes
* C11 test against the real libelizainference (ABI v19). Always runs:
* - ABI string "19" and the v17 capability probes
* - v18 llm_context_capacity argument validation and the no-text-GGUF error
* - cancel_tts / asr_cancel are no-ops with nothing in flight
* - mmap_evict / mmap_acquire("tts") on a bundle with no TTS weights
Expand Down Expand Up @@ -50,7 +50,7 @@ static double now_ms(void) {

static void test_probes_and_noops(void) {
char * err = NULL;
CHECK(strcmp(eliza_inference_abi_version(), "18") == 0);
CHECK(strcmp(eliza_inference_abi_version(), "19") == 0);
CHECK_RC(eliza_inference_asr_supported(), 1);
#if defined(ELIZA_TEST_EXPECT_PARADEE)
CHECK_RC(eliza_inference_paradee_supported(), 1);
Expand Down
59 changes: 57 additions & 2 deletions tools/omnivoice/tests/ffi-paradee-asr.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
// - eliza_inference_asr_cancel during prefill and during decode, over a
// scripted ASR engine that polls the same abort callback llama_decode
// uses
// - ABI string "18"
// - ABI string "19"
//
// The bridge is compiled into this TU (ELIZA_INFERENCE_TEST_HOOKS) against
// the Paradee / misaki doubles in tests/paradee-contract/, so no weights are
Expand Down Expand Up @@ -86,7 +86,7 @@ static int synth_text(EliInferenceContext * ctx, const std::string & text, size_
}

static void test_abi_and_probes() {
CHECK(std::string(eliza_inference_abi_version()) == "18");
CHECK(std::string(eliza_inference_abi_version()) == "19");
CHECK_RC(eliza_inference_paradee_supported(), 1);
CHECK_RC(eliza_inference_asr_supported(), 1);
CHECK_RC(eliza_inference_asr_stream_supported(), 0);
Expand Down Expand Up @@ -188,6 +188,60 @@ static void test_paradee_load_and_synth() {
fs::remove_all(bundle);
}

static void test_paradee_streaming() {
const fs::path bundle = make_bundle("paradee-streaming");
char * err = nullptr;
EliInferenceContext * ctx = eliza_inference_create(bundle.string().c_str(), &err);
CHECK_RC(eliza_inference_paradee_stream_supported(), 1);
CHECK_RC(eliza_inference_paradee_load(ctx, "/x/paradee.gguf", "/x/lex.bin", nullptr, &err), ELIZA_OK);
std::string text;
for (int i = 0; i < 40; ++i) text += "the quick brown fox jumps now. ";
float * expected = nullptr; size_t count = 0;
CHECK_RC(eliza_inference_paradee_synthesize_alloc(ctx, text.data(), text.size(), 1, &expected, &count, &err), ELIZA_OK);
struct Output {
std::vector<float> pcm;
int final = 0, calls = 0, synth_before = 0;
bool cancel = false, cancel_final = false;
EliInferenceContext * cancel_context = nullptr;
} output;
output.synth_before = fake_paradee().synth_calls.load();
auto receive = [](const float * pcm, size_t n, int final, void * user) {
auto & out = *static_cast<Output *>(user);
if (final) { ++out.final; CHECK(n == 0 && pcm == nullptr); return out.cancel_final ? 1 : 0; }
CHECK(n <= 24000 && out.final == 0);
if (out.calls == 0) CHECK(fake_paradee().synth_calls.load() == out.synth_before + 1);
++out.calls;
if (out.cancel_context) CHECK_RC(eliza_inference_cancel_tts(out.cancel_context, nullptr), ELIZA_OK);
out.pcm.insert(out.pcm.end(), pcm, pcm + n);
return out.cancel ? 1 : 0;
};
CHECK_RC(eliza_inference_paradee_synthesize_stream(ctx, text.data(), text.size(), 1, receive, &output, &err), ELIZA_OK);
CHECK(output.calls > 1 && output.final == 1);
CHECK(output.pcm == std::vector<float>(expected, expected + count));
eliza_inference_free_pcm(expected);
output = Output{}; output.cancel = true; output.synth_before = fake_paradee().synth_calls.load();
CHECK_RC(eliza_inference_paradee_synthesize_stream(ctx, text.data(), text.size(), 1, receive, &output, &err), ELIZA_ERR_CANCELLED);
CHECK(output.calls == 1 && output.final == 0);
CHECK(fake_paradee().synth_calls.load() == output.synth_before + 1);
clear_err(&err);
output = Output{}; output.cancel_context = ctx; output.synth_before = fake_paradee().synth_calls.load();
CHECK_RC(eliza_inference_paradee_synthesize_stream(ctx, text.data(), text.size(), 1, receive, &output, &err), ELIZA_ERR_CANCELLED);
CHECK(output.calls == 1 && output.final == 0);
CHECK(fake_paradee().synth_calls.load() == output.synth_before + 1);
clear_err(&err);
output = Output{}; output.cancel_final = true; output.synth_before = fake_paradee().synth_calls.load();
CHECK_RC(eliza_inference_paradee_synthesize_stream(ctx, "hi", 2, 1, receive, &output, &err), ELIZA_ERR_CANCELLED);
CHECK(output.calls == 1 && output.final == 1);
clear_err(&err);
output = Output{};
CHECK_RC(eliza_inference_paradee_synthesize_stream(ctx, "12345", 5, 1, receive, &output, &err), ELIZA_OK);
CHECK(output.calls == 0 && output.final == 1);
CHECK_RC(eliza_inference_paradee_synthesize_stream(ctx, "hi", 2, 1, nullptr, &output, &err), ELIZA_ERR_INVALID_ARG);
clear_err(&err);
eliza_inference_destroy(ctx);
fs::remove_all(bundle);
}

static void test_paradee_chunking() {
fake_paradee_reset();
const fs::path bundle = make_bundle("chunk");
Expand Down Expand Up @@ -617,6 +671,7 @@ int main() {
test_abi_and_probes();
test_paradee_load_and_synth();
test_paradee_chunking();
test_paradee_streaming();
test_paradee_cancel();
test_paradee_race();
test_evict_reload();
Expand Down
32 changes: 32 additions & 0 deletions tools/omnivoice/tests/paradee-stream-output.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#include "../src/paradee-stream-output.h"
#ifdef NDEBUG
#undef NDEBUG
#endif
#include <cassert>
#include <vector>

int main() {
std::vector<float> pcm(48007);
for (size_t i = 0; i < pcm.size(); ++i) pcm[i] = float(i);
std::vector<float> delivered;
std::vector<size_t> sizes;
bool cancelled = false;
auto consume = [&](const float * data, size_t n, int final, void *) {
assert(!final && n <= 24000);
sizes.push_back(n);
delivered.insert(delivered.end(), data, data + n);
return 0;
};
assert(eliza_paradee_stream_output(pcm.data(), pcm.size(), consume, nullptr, [&] { return cancelled; }));
assert(delivered == pcm && sizes == std::vector<size_t>({24000, 24000, 7}));
int calls = 0;
assert(!eliza_paradee_stream_output(pcm.data(), pcm.size(),
[&](const float *, size_t, int, void *) { ++calls; return 1; }, nullptr, [] { return false; }));
assert(calls == 1);
calls = 0;
assert(!eliza_paradee_stream_output(pcm.data(), pcm.size(),
[&](const float *, size_t, int, void *) { ++calls; cancelled = true; return 0; }, nullptr, [&] { return cancelled; }));
assert(calls == 1);
assert(!eliza_paradee_stream_output(pcm.data(), pcm.size(), consume, nullptr, [] { return true; }));
assert(eliza_paradee_stream_output(nullptr, 0, consume, nullptr, [] { return false; }));
}
Loading