diff --git a/.github/workflows/eliza-paradee-stream.yml b/.github/workflows/eliza-paradee-stream.yml new file mode 100644 index 000000000..4940c339f --- /dev/null +++ b/.github/workflows/eliza-paradee-stream.yml @@ -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)$' diff --git a/tools/omnivoice/CMakeLists.txt b/tools/omnivoice/CMakeLists.txt index 2838abec4..c57336785 100644 --- a/tools/omnivoice/CMakeLists.txt +++ b/tools/omnivoice/CMakeLists.txt @@ -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 diff --git a/tools/omnivoice/include/eliza-inference-ffi.h b/tools/omnivoice/include/eliza-inference-ffi.h index 769cc3cca..94b4f8323 100644 --- a/tools/omnivoice/include/eliza-inference-ffi.h +++ b/tools/omnivoice/include/eliza-inference-ffi.h @@ -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.) @@ -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 @@ -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); @@ -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. */ diff --git a/tools/omnivoice/src/eliza-inference-ffi.cpp b/tools/omnivoice/src/eliza-inference-ffi.cpp index 41fab4a3c..9fba3b373 100644 --- a/tools/omnivoice/src/eliza-inference-ffi.cpp +++ b/tools/omnivoice/src/eliza-inference-ffi.cpp @@ -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" @@ -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"); @@ -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(std::malloc(pcm.size() * sizeof(float))); @@ -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); } diff --git a/tools/omnivoice/src/paradee-stream-output.h b/tools/omnivoice/src/paradee-stream-output.h new file mode 100644 index 000000000..c36dc4c75 --- /dev/null +++ b/tools/omnivoice/src/paradee-stream-output.h @@ -0,0 +1,17 @@ +#pragma once +#include +#include + +// Callback lifetime and backpressure apply to each bounded PCM window. +template +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(); +} diff --git a/tools/omnivoice/tests/ffi-abi.c b/tools/omnivoice/tests/ffi-abi.c index 8dd147450..02daef9f4 100644 --- a/tools/omnivoice/tests/ffi-abi.c +++ b/tools/omnivoice/tests/ffi-abi.c @@ -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 @@ -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); diff --git a/tools/omnivoice/tests/ffi-paradee-asr.cpp b/tools/omnivoice/tests/ffi-paradee-asr.cpp index 804d95db8..395fc3fbf 100644 --- a/tools/omnivoice/tests/ffi-paradee-asr.cpp +++ b/tools/omnivoice/tests/ffi-paradee-asr.cpp @@ -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 @@ -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); @@ -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 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(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(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"); @@ -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(); diff --git a/tools/omnivoice/tests/paradee-stream-output.cpp b/tools/omnivoice/tests/paradee-stream-output.cpp new file mode 100644 index 000000000..c98b6f465 --- /dev/null +++ b/tools/omnivoice/tests/paradee-stream-output.cpp @@ -0,0 +1,32 @@ +#include "../src/paradee-stream-output.h" +#ifdef NDEBUG +#undef NDEBUG +#endif +#include +#include + +int main() { + std::vector pcm(48007); + for (size_t i = 0; i < pcm.size(); ++i) pcm[i] = float(i); + std::vector delivered; + std::vector 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({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; })); +}