11#include " BaseModelTests.h"
2+ #include " utils/TestUtils.h"
23#include < gtest/gtest.h>
34#include < memory>
45#include < string>
6+ #include < utility>
7+ #include < vector>
58
69#include < ReactCommon/CallInvoker.h>
710#include < rnexecutorch/Error.h>
811#include < rnexecutorch/models/llm/LLM.h>
12+ #include < runner/encoders/audio_encoder.h>
913#include < runner/encoders/vision_encoder.h>
1014
1115using namespace rnexecutorch ;
@@ -30,6 +34,12 @@ std::string formatChatML(const std::string &systemPrompt,
3034 " <|im_start|>assistant\n " ;
3135}
3236
37+ // Helper to format a single-turn prompt in Gemma's chat template.
38+ std::string formatGemma (const std::string &userMessage) {
39+ return " <start_of_turn>user\n " + userMessage + " <end_of_turn>\n " +
40+ " <start_of_turn>model\n " ;
41+ }
42+
3343// ============================================================================
3444// Common tests via typed test suite
3545// ============================================================================
@@ -227,6 +237,18 @@ TEST(VisionEncoderTest, LoadFailsWithClearErrorWhenMethodMissing) {
227237 EXPECT_THROW (encoder->load (), rnexecutorch::RnExecutorchError);
228238}
229239
240+ TEST (AudioEncoderTest, LoadFailsWithClearErrorWhenMethodMissing) {
241+ // smolLm2_135M_8da4w.pte has no audio_encoder method
242+ auto module = std::make_unique<::executorch::extension::Module>(
243+ " smolLm2_135M_8da4w.pte" ,
244+ ::executorch::extension::Module::LoadMode::File);
245+
246+ auto encoder =
247+ std::make_unique<executorch::extension::llm::AudioEncoder>(*module );
248+
249+ EXPECT_THROW (encoder->load (), rnexecutorch::RnExecutorchError);
250+ }
251+
230252// ============================================================================
231253// VLM-specific tests
232254// ============================================================================
@@ -243,7 +265,11 @@ TEST_F(LLMTest, TextModelIsNotMultimodal) {
243265
244266TEST_F (LLMTest, GenerateMultimodalOnTextModelThrows) {
245267 LLM model (kValidModelPath , kValidTokenizerPath , {}, mockInvoker_);
246- EXPECT_THROW (model.generateMultimodal (" hello" , {}, " <image>" , nullptr ),
268+ // A text-only runner reports is_multimodal() == false, so any multimodal
269+ // call must be rejected before the inputs are even inspected.
270+ MultimodalInputs inputs{.images =
271+ ImageInputs{.paths = {}, .token = " <image>" }};
272+ EXPECT_THROW (model.generateMultimodal (" hello" , nullptr , std::move (inputs)),
247273 RnExecutorchError);
248274}
249275
@@ -270,22 +296,120 @@ std::shared_ptr<facebook::react::CallInvoker> VLMTest::invoker_;
270296std::unique_ptr<LLM > VLMTest::model_;
271297
272298TEST_F (VLMTest, GenerateMultimodalEmptyImageTokenThrows) {
273- EXPECT_THROW (
274- model_->generateMultimodal (" hello" , {kTestImagePath }, " " , nullptr ),
275- RnExecutorchError);
299+ MultimodalInputs inputs{
300+ .images = ImageInputs{.paths = {kTestImagePath }, .token = " " }};
301+ EXPECT_THROW (model_->generateMultimodal (" hello" , nullptr , std::move (inputs)),
302+ RnExecutorchError);
276303}
277304
278305TEST_F (VLMTest, GenerateMultimodalMorePlaceholdersThanImagePaths) {
279306 std::string prompt = std::string (kVlmImageToken ) + " and " + kVlmImageToken ;
280- EXPECT_THROW (model_->generateMultimodal (prompt, {kTestImagePath },
281- kVlmImageToken , nullptr ),
307+ MultimodalInputs inputs{.images = ImageInputs{.paths = {kTestImagePath },
308+ .token = kVlmImageToken }};
309+ EXPECT_THROW (model_->generateMultimodal (prompt, nullptr , std::move (inputs)),
282310 RnExecutorchError);
283311}
284312
285313TEST_F (VLMTest, GenerateMultimodalMoreImagePathsThanPlaceholders) {
286314 std::string prompt = std::string (kVlmImageToken ) + " describe" ;
287- EXPECT_THROW (model_->generateMultimodal (prompt,
288- {kTestImagePath , kTestImagePath },
289- kVlmImageToken , nullptr ),
315+ MultimodalInputs inputs{
316+ .images = ImageInputs{.paths = {kTestImagePath , kTestImagePath },
317+ .token = kVlmImageToken }};
318+ EXPECT_THROW (model_->generateMultimodal (prompt, nullptr , std::move (inputs)),
319+ RnExecutorchError);
320+ }
321+
322+ // ============================================================================
323+ // Audio (Gemma 4) multimodal tests
324+ // ============================================================================
325+ constexpr auto kGemmaModelPath = " gemma4_e2b_mm_xnnpack.pte" ;
326+ constexpr auto kGemmaTokenizerPath = " gemma_tokenizer.json" ;
327+ constexpr auto kGemmaAudioToken = " <audio_soft_token>" ;
328+ constexpr auto kTestAudioPath = " test_audio_float.raw" ;
329+
330+ // Fixture that loads the audio-capable Gemma model once for all audio tests.
331+ class GemmaAudioTest : public ::testing::Test {
332+ protected:
333+ static void SetUpTestSuite () {
334+ invoker_ = createMockCallInvoker ();
335+ model_ = std::make_unique<LLM >(kGemmaModelPath , kGemmaTokenizerPath ,
336+ std::vector<std::string>{" vision" , " audio" },
337+ invoker_);
338+ }
339+
340+ static void TearDownTestSuite () {
341+ model_.reset ();
342+ invoker_.reset ();
343+ }
344+
345+ static std::vector<float > loadAudio (size_t maxSamples = 32000 ) {
346+ auto wav = test_utils::loadAudioFromFile (kTestAudioPath );
347+ if (wav.size () > maxSamples) {
348+ wav.resize (maxSamples);
349+ }
350+ return wav;
351+ }
352+
353+ static std::shared_ptr<facebook::react::CallInvoker> invoker_;
354+ static std::unique_ptr<LLM > model_;
355+ };
356+
357+ std::shared_ptr<facebook::react::CallInvoker> GemmaAudioTest::invoker_;
358+ std::unique_ptr<LLM > GemmaAudioTest::model_;
359+
360+ TEST_F (GemmaAudioTest, GenerateMultimodalNoInputsThrows) {
361+ EXPECT_THROW (model_->generateMultimodal (" hello" , nullptr , {}),
362+ RnExecutorchError);
363+ }
364+
365+ TEST_F (GemmaAudioTest, GenerateMultimodalEmptyAudioTokenThrows) {
366+ MultimodalInputs inputs{
367+ .audios = AudioInputs{.waveforms = {loadAudio ()}, .token = " " }};
368+ EXPECT_THROW (model_->generateMultimodal (" hello" , nullptr , std::move (inputs)),
369+ RnExecutorchError);
370+ }
371+
372+ TEST_F (GemmaAudioTest, GenerateMultimodalMorePlaceholdersThanWaveformsThrows) {
373+ std::string prompt =
374+ std::string (kGemmaAudioToken ) + " and " + kGemmaAudioToken ;
375+ MultimodalInputs inputs{.audios = AudioInputs{.waveforms = {loadAudio ()},
376+ .token = kGemmaAudioToken }};
377+ EXPECT_THROW (model_->generateMultimodal (prompt, nullptr , std::move (inputs)),
290378 RnExecutorchError);
291379}
380+
381+ TEST_F (GemmaAudioTest, GenerateMultimodalMoreWaveformsThanPlaceholdersThrows) {
382+ std::string prompt = std::string (kGemmaAudioToken ) + " describe" ;
383+ MultimodalInputs inputs{
384+ .audios = AudioInputs{.waveforms = {loadAudio (), loadAudio ()},
385+ .token = kGemmaAudioToken }};
386+ EXPECT_THROW (model_->generateMultimodal (prompt, nullptr , std::move (inputs)),
387+ RnExecutorchError);
388+ }
389+
390+ TEST_F (GemmaAudioTest, GenerateMultimodalAudioProducesOutput) {
391+ std::vector<float > wav = loadAudio ();
392+ ASSERT_FALSE (wav.empty ())
393+ << " test_audio_float.raw missing on device - check run_tests.sh assets" ;
394+
395+ std::string prompt =
396+ formatGemma (std::string (kGemmaAudioToken ) + " Transcribe the audio." );
397+ MultimodalInputs inputs{.audios = AudioInputs{.waveforms = {std::move (wav)},
398+ .token = kGemmaAudioToken }};
399+ std::string output =
400+ model_->generateMultimodal (prompt, nullptr , std::move (inputs));
401+
402+ EXPECT_FALSE (output.empty ());
403+ EXPECT_GT (model_->getGeneratedTokenCount (), 0 );
404+ }
405+
406+ TEST_F (GemmaAudioTest, GenerateMultimodalInterleavedTextAndAudio) {
407+ std::string prompt = formatGemma (" Listen: " + std::string (kGemmaAudioToken ) +
408+ " then summarise it." );
409+ MultimodalInputs inputs{.audios = AudioInputs{.waveforms = {loadAudio ()},
410+ .token = kGemmaAudioToken }};
411+ std::string output =
412+ model_->generateMultimodal (prompt, nullptr , std::move (inputs));
413+
414+ EXPECT_FALSE (output.empty ());
415+ }
0 commit comments