Skip to content
Draft
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
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,41 @@
</table>


## 🤖 Mimo Bot — voice companion (work in progress)

A small ESP32-S3 device acts as a mic + speaker for the phone; the phone runs
the full Whisper → LLM → TTS pipeline using LLM Hub's existing inference core.
Nothing leaves the pair of devices.

```
┌───────── ESP32-S3 ─────────┐ ┌───── Phone (LLM Hub) ─────┐
│ mic → Opus → BLE ────────┼──────┼► Whisper → LLM → TTS │
│ spk ← Opus ← BLE ────────┼──────┼◄ Opus ← TTS (Kokoro/Sys) │
└────────────────────────────┘ └───────────────────────────┘
```

Status: **scaffolding only.** The transport, audio codec, Whisper, TTS, and
pipeline modules are stubs with `TODO(...)` markers and a locked wire protocol
so firmware and apps can be built in parallel without re-negotiating.

- `docs/mimobot-protocol.md` — BLE service + control JSON schema
- `android/app/src/main/java/com/llmhub/llmhub/mimobot/` — Android pipeline
(`transport/`, `audio/`, `speech/`, `pipeline/`)
- `ios/LLMHub/Sources/LLMHub/MimoBot/` — iOS pipeline (same shape)
- `firmware/` — ESP-IDF v5.2+ skeleton for the ESP32-S3

The pipeline reuses the existing `InferenceService` / `LLMBackend`,
`RagService`, and web-search services unchanged — voice is purely additive.

TTS is pluggable via a `Tts` / `TTS` interface; two implementations are
stubbed out:
- **SystemTts / SystemTTS** — free, ships with the OS, decent quality
- **KokoroTts / KokoroTTS** — Kokoro-82M via ONNX Runtime (already a
dependency), higher quality, one ~160 MB download

See `firmware/README.md` for hardware and toolchain.


## 📸 Screenshots

<div style="display:flex;gap:12px;flex-wrap:wrap;align-items:flex-start;">
Expand Down
14 changes: 14 additions & 0 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,20 @@ android {
}

// Removed externalNativeBuild - now using MediaPipe instead of native llama.cpp

// Mimo Bot espeak-ng JNI bridge. Only enabled when the user has run
// scripts/build_espeak_android.sh and the headers landed under
// src/main/cpp/espeak-ng/include/. Skipping the block on fresh checkouts
// means AGP doesn't require an NDK install for users who only want the
// bundled DictionaryG2P fallback.
if (file("src/main/cpp/espeak-ng/include/speak_lib.h").exists()) {
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
version = "3.22.1"
}
}
}
}

// The Image Generator requires full protobuf-java, not the lite version
Expand Down
4 changes: 4 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
<intent>
<action android:name="android.intent.action.TTS_SERVICE" />
</intent>
<!-- Required on Android 11+ so SpeechRecognizer can bind to the on-device recognizer. -->
<intent>
<action android:name="android.speech.RecognitionService" />
</intent>
</queries>
<!-- end -->

Expand Down
39 changes: 39 additions & 0 deletions android/app/src/main/cpp/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# JNI bridge that exposes espeak-ng's text→IPA function to Kotlin.
#
# Layout assumed (populated by scripts/build_espeak_android.sh):
# src/main/cpp/
# espeak_jni.cpp — this directory
# espeak-ng/include/ — espeak-ng public headers (speak_lib.h)
# src/main/jniLibs/<abi>/
# libespeak-ng.so — cross-compiled espeak-ng runtime per ABI
# src/main/assets/espeak-ng-data/
# ... — espeak-ng's data files (voices, phoneme tables)
#
# If espeak-ng/include/ doesn't exist (e.g. fresh checkout), this file does
# nothing — the Kotlin layer's `EspeakG2P.tryLoad()` will return null and the
# pipeline falls back to the bundled DictionaryG2P.

cmake_minimum_required(VERSION 3.18.1)
project(mimo_espeak_jni LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)

set(ESPEAK_INCLUDE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/espeak-ng/include")

if(NOT EXISTS "${ESPEAK_INCLUDE_DIR}/speak_lib.h")
message(WARNING "espeak-ng headers not found at ${ESPEAK_INCLUDE_DIR}. "
"Run scripts/build_espeak_android.sh to set them up. "
"Skipping espeak JNI build.")
return()
endif()

# Locate the per-ABI prebuilt libespeak-ng.so that the build script dropped
# under src/main/jniLibs/<abi>/. Gradle automatically packages everything in
# jniLibs into the APK/AAB, so we just declare the import target here.
add_library(espeak-ng SHARED IMPORTED)
set_target_properties(espeak-ng PROPERTIES
IMPORTED_LOCATION "${CMAKE_SOURCE_DIR}/../jniLibs/${ANDROID_ABI}/libespeak-ng.so")

add_library(espeak-jni SHARED espeak_jni.cpp)
target_include_directories(espeak-jni PRIVATE "${ESPEAK_INCLUDE_DIR}")
target_link_libraries(espeak-jni PRIVATE espeak-ng log)
95 changes: 95 additions & 0 deletions android/app/src/main/cpp/espeak_jni.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// Mimo bot — JNI bridge from Kotlin to espeak-ng for text→IPA phonemization.
//
// Symbols exported here are loaded by EspeakG2P.kt via System.loadLibrary.
// All entry points are namespace `Java_com_llmhub_llmhub_mimobot_speech_kokoro_EspeakG2P_*`.
//
// espeak-ng API reference: speak_lib.h
// espeak_Initialize(AUDIO_OUTPUT_SYNCHRONOUS, 0, dataPath, 0)
// espeak_SetVoiceByName("en-us")
// espeak_TextToPhonemes(&inputBuf, espeakCHARS_AUTO,
// espeakPHONEMES_IPA | espeakPHONEMES_SHOW)
//
// We deliberately keep this thin: tokenisation / vocabulary mapping happens in
// Kotlin (KokoroVocab) so we can swap espeak for another phonemizer (e.g.
// misaki, custom CMU dict) without touching native code.

#include <jni.h>
#include <cstring>
#include <string>
#include <android/log.h>

extern "C" {
#include "speak_lib.h"
}

#define LOG_TAG "MimoEspeakJni"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__)

static bool g_initialised = false;

extern "C" JNIEXPORT jint JNICALL
Java_com_llmhub_llmhub_mimobot_speech_kokoro_EspeakG2P_nativeInit(
JNIEnv* env, jclass /*clazz*/, jstring jDataPath) {
if (g_initialised) return 0;
const char* dataPath = env->GetStringUTFChars(jDataPath, nullptr);
// AUDIO_OUTPUT_SYNCHRONOUS with no buffer — we never play audio, only ask
// for phonemes. Returning sample rate or -1 on failure.
int rate = espeak_Initialize(AUDIO_OUTPUT_SYNCHRONOUS, 0, dataPath, 0);
env->ReleaseStringUTFChars(jDataPath, dataPath);
if (rate < 0) {
LOGW("espeak_Initialize failed (rate=%d)", rate);
return rate;
}
g_initialised = true;
LOGI("espeak-ng initialised (rate=%d)", rate);
return 0;
}

extern "C" JNIEXPORT jint JNICALL
Java_com_llmhub_llmhub_mimobot_speech_kokoro_EspeakG2P_nativeSetVoice(
JNIEnv* env, jclass /*clazz*/, jstring jVoiceName) {
const char* voice = env->GetStringUTFChars(jVoiceName, nullptr);
espeak_ERROR rc = espeak_SetVoiceByName(voice);
env->ReleaseStringUTFChars(jVoiceName, voice);
if (rc != EE_OK) {
LOGW("espeak_SetVoiceByName(%s) failed rc=%d", voice, (int)rc);
return -1;
}
return 0;
}

extern "C" JNIEXPORT jstring JNICALL
Java_com_llmhub_llmhub_mimobot_speech_kokoro_EspeakG2P_nativePhonemize(
JNIEnv* env, jclass /*clazz*/, jstring jText) {
if (!g_initialised) return env->NewStringUTF("");
const char* utf = env->GetStringUTFChars(jText, nullptr);

// espeak_TextToPhonemes:
// - mode bits: 0x02 = IPA, 0x01 = print stress markers (we want both).
// espeakPHONEMES_IPA = 0x02
// espeakPHONEMES_SHOW = 0x01 (returned, not printed when no FILE*)
// - input is consumed; we pass &input which the function advances.
const void* input = utf;
std::string out;
while (input != nullptr) {
const char* phonemes = espeak_TextToPhonemes(
&input,
/*textmode*/ espeakCHARS_AUTO,
/*phonememode*/ 0x02); // IPA, no stress separators
if (phonemes == nullptr) break;
if (!out.empty() && out.back() != ' ') out.push_back(' ');
out.append(phonemes);
}

env->ReleaseStringUTFChars(jText, utf);
return env->NewStringUTF(out.c_str());
}

extern "C" JNIEXPORT void JNICALL
Java_com_llmhub_llmhub_mimobot_speech_kokoro_EspeakG2P_nativeShutdown(
JNIEnv* /*env*/, jclass /*clazz*/) {
if (!g_initialised) return;
espeak_Terminate();
g_initialised = false;
}
70 changes: 70 additions & 0 deletions android/app/src/main/java/com/llmhub/llmhub/data/ModelData.kt
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,76 @@ object DeviceInfo {

object ModelData {
val models = listOf(
// Gemma-3 270M — smallest Gemma 3, great for fast voice replies on the
// Mimo Bot test screen. Multiple quantization × KV-cache variants
// following the same litert-community filename convention as Gemma-3 1B.
// If any URL 404s, the rest of the entry stays correct — just edit the URL.
LLMModel(
name = "Gemma-3 270M (INT4, 1k)",
description = "Google Gemma-3 270M, INT4 quantization, 1k context. Smallest + fastest. Ideal for low-latency voice replies. (~150 MB)",
url = "https://huggingface.co/litert-community/Gemma3-270M-IT/resolve/main/Gemma3-270M-IT_multi-prefill-seq_q4_ekv1024.task?download=true",
category = "text",
sizeBytes = 157286400L, // ~150 MB
source = "Google via LiteRT Community",
supportsVision = false,
supportsGpu = true,
requirements = ModelRequirements(minRamGB = 1, recommendedRamGB = 2),
contextWindowSize = 1024,
modelFormat = "task"
),
LLMModel(
name = "Gemma-3 270M (INT4, 2k)",
description = "Google Gemma-3 270M, INT4, 2k context. Slightly more memory for longer turns. (~160 MB)",
url = "https://huggingface.co/litert-community/Gemma3-270M-IT/resolve/main/Gemma3-270M-IT_multi-prefill-seq_q4_ekv2048.task?download=true",
category = "text",
sizeBytes = 167772160L, // ~160 MB
source = "Google via LiteRT Community",
supportsVision = false,
supportsGpu = true,
requirements = ModelRequirements(minRamGB = 1, recommendedRamGB = 2),
contextWindowSize = 2048,
modelFormat = "task"
),
LLMModel(
name = "Gemma-3 270M (INT8, 1k)",
description = "Google Gemma-3 270M, INT8 quantization, 1k context. Higher quality, still fast. (~270 MB)",
url = "https://huggingface.co/litert-community/Gemma3-270M-IT/resolve/main/Gemma3-270M-IT_multi-prefill-seq_q8_ekv1024.task?download=true",
category = "text",
sizeBytes = 283115520L, // ~270 MB
source = "Google via LiteRT Community",
supportsVision = false,
supportsGpu = true,
requirements = ModelRequirements(minRamGB = 1, recommendedRamGB = 2),
contextWindowSize = 1024,
modelFormat = "task"
),
LLMModel(
name = "Gemma-3 270M (INT8, 2k)",
description = "Google Gemma-3 270M, INT8, 2k context. Best balance of quality + context for the Mimo Bot loop. (~280 MB)",
url = "https://huggingface.co/litert-community/Gemma3-270M-IT/resolve/main/Gemma3-270M-IT_multi-prefill-seq_q8_ekv2048.task?download=true",
category = "text",
sizeBytes = 293601280L, // ~280 MB
source = "Google via LiteRT Community",
supportsVision = false,
supportsGpu = true,
requirements = ModelRequirements(minRamGB = 1, recommendedRamGB = 2),
contextWindowSize = 2048,
modelFormat = "task"
),
LLMModel(
name = "Gemma-3 270M (INT8, 4k)",
description = "Google Gemma-3 270M, INT8, 4k context. For longer multi-turn voice sessions. (~290 MB)",
url = "https://huggingface.co/litert-community/Gemma3-270M-IT/resolve/main/Gemma3-270M-IT_multi-prefill-seq_q8_ekv4096.task?download=true",
category = "text",
sizeBytes = 304087040L, // ~290 MB
source = "Google via LiteRT Community",
supportsVision = false,
supportsGpu = true,
requirements = ModelRequirements(minRamGB = 2, recommendedRamGB = 3),
contextWindowSize = 4096,
modelFormat = "task"
),

// Gemma-3 1B Models
LLMModel(
name = "Gemma-3 1B (INT4, 2k)",
Expand Down
23 changes: 23 additions & 0 deletions android/app/src/main/java/com/llmhub/llmhub/mimobot/MimoBotIds.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.llmhub.llmhub.mimobot

import java.util.UUID

/**
* Canonical identifiers shared across the Mimo bot stack.
* Must stay in sync with docs/mimobot-protocol.md and firmware/main/ble_transport.c.
*/
object MimoBotIds {
val SERVICE: UUID = UUID.fromString("6d696d6f-b07d-4e13-9e88-000000000001")
val AUDIO_UP: UUID = UUID.fromString("6d696d6f-b07d-4e13-9e88-000000000010")
val AUDIO_DOWN: UUID = UUID.fromString("6d696d6f-b07d-4e13-9e88-000000000011")
val CONTROL: UUID = UUID.fromString("6d696d6f-b07d-4e13-9e88-000000000012")

const val ADV_NAME_PREFIX = "mimobot-"

// Audio format constants (see docs/mimobot-protocol.md)
const val SAMPLE_RATE_HZ = 16_000
const val FRAME_MS = 20
const val FRAME_SAMPLES = SAMPLE_RATE_HZ * FRAME_MS / 1000 // 320
const val CHANNELS = 1
const val OPUS_BITRATE = 24_000
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.llmhub.llmhub.mimobot.audio

import kotlinx.coroutines.flow.Flow

/**
* Abstractions that let [com.llmhub.llmhub.mimobot.pipeline.VoicePipeline] run
* against either the phone's own mic+speaker (local dev mode) or a BLE
* transport with Opus codec (real device mode).
*
* Contract: all frames are 16 kHz mono Int16 PCM, 320 samples (20 ms) each.
*/
interface AudioSource {
/** Start capturing; flow completes when [stop] is called. */
fun frames(): Flow<ShortArray>
fun stop()
}

interface AudioSink {
/** Must be called before [write]. Idempotent. */
fun start()
suspend fun write(frame: ShortArray)
fun stop()
}
Loading