On-Device AI in Kotlin
In this Kotlin tutorial, you'll learn how to run a large language model (LLM) directly on a user's device: no server, no API key needed. We'll start from scratch with a simple chat exchange, and progressively introduce more advanced features: multimodal input, speech-to-text, text-to-speech, voice activity detection, tool calling and RAG. Each concept is explained before the code, so you can follow along whether you're new to on-device AI. Why run AI On-Device? Most AI features rely on a cloud API: you send a request to a remote server, it runs the model, and sends a response back. That works well, but it comes with tradeoffs. Running the model directly on the device avoids all of them: - Works offline - no internet connection required - Privacy by design - user data never leaves the device - Low latency - no network round-trip - No cloud costs - inference is free The tradeoff is raw capability: on-device models are smaller and less powerful than frontier cloud models. But for many use cases like summarization, chatbots, or local search, they're more than good enough. About NobodyWho We'll use the NobodyWho library throughout this tutorial. It wraps llama.cpp in Rust and ships bindings for several languages and frameworks: Kotlin, Python, Expo/React Native, Swift, Flutter & Godot. It exposes a clean API for running any model locally in .gguf format, on Android and desktop JVM (Linux, macOS, Windows). Add it to your build.gradle.kts : // Android implementation("ai.nobodywho:nobodywho-android:2.2.0") // Desktop JVM (Linux, macOS, Windows) implementation("ai.nobodywho:nobodywho:2.2.0") Loading a Model NobodyWho can download a GGUF model for you directly from Hugging Face, cache it, and reuse it on every subsequent launch. That means you don't need to bundle anything into your app or manage downloads yourself: import ai.nobodywho.Chat val chat = Chat.fromPath( modelPath = "hf://NobodyWho/Qwen_Qwen3-0.6B-GGUF/Qwen_Qwen3-0.6B-Q4_K_M.gguf" ) The first time this runs, the model is downloaded to the platform cache directory. Every call after that loads the model directly. modelPath accepts a few different forms: | Form | Example | Notes | |---|---|---| | HuggingFace reference | hf:owner/repo/file.gguf | Downloaded and cached on first use | | HTTPS URL | https://example.com/model.gguf | Downloaded and cached on first use | | Local path | ./model.gguf | Used as-is, no download | The HuggingFace prefix is case-insensitive and the // is optional, so hf: , hf:// , huggingface: , and huggingface:// are all equivalent. You can also pass "auto" to let NobodyWho pick a chat model based on the device's available memory, which is a handy default if you don't want to think about model selection at all. You can track a remote download by passing a callback as the trailing lambda to Chat.fromPath . It receives (downloadedBytes, totalBytes) and is skipped for cached or local files: val chat = Chat.fromPath( modelPath = "hf://NobodyWho/Qwen_Qwen3-0.6B-GGUF/Qwen_Qwen3-0.6B-Q4_K_M.gguf" ) { downloaded, total -> println("$downloaded / $total bytes") } You can find thousands of LLMs in .gguf format on Hugging Face here. Basic Chat With a model loaded, you're ready to start a conversation: import ai.nobodywho.Chat import kotlinx.coroutines.runBlocking fun main() = runBlocking { val chat = Chat.fromPath( modelPath = "hf://NobodyWho/Qwen_Qwen3-0.6B-GGUF/Qwen_Qwen3-0.6B-Q4_K_M.gguf" ) val response = chat.ask("Is water wet?").completed() println(response) // Yes, indeed, water is wet! } On Android, use lifecycleScope or viewModelScope instead of runBlocking . chat.ask() sends your message and returns a TokenStream . Calling .completed() waits for the whole response and gives you back the final string, which is fine for a one-off question. But a real chat interface needs to stream tokens as they arrive, otherwise users stare at a blank screen until generation finishes. Streaming Tokens chat.ask("What is the capital of Denmark?").asFlow().collect { token -> print(token) } A token is the smallest unit a model generates, typically a word, or a fragment of a word. Multimodal Models Some models can natively ingest images and audio. To use them, you need two things: a multimodal LLM, and its projection model that converts images and/or audio into tokens the LLM can consume (usually named with mmproj in it). A solid default that handles both image and audio is Gemma 4 with its BF16 projection model. import ai.nobodywho.Model import ai.nobodywho.Chat val model = Model.load( modelPath = "./vision-model.gguf", projectionModelPath = "./mmproj.gguf" ) val chat = Chat(model = model) To actually send image or audio content, build a Prompt mixing text, images, and audio, and pass it to chat.ask() instead of a plain string: import ai.nobodywho.Prompt val response = chat.ask(Prompt( Prompt.Text("Tell me what you see in the image and what you hear in the audio."), Prompt.Image("./dog.png"), Prompt.Audio("./sound.mp3"), )).completed() println(response) Keep in mind that images and audio consume context fast, so you'll likely want a bigger contextSize than you'd use for text-only chat. Also note that the language model and its projection model have to be trained together - you can't mix an LLM and a projection model you happen to like and expect them to work. Speech to Text If you'd rather transcribe spoken audio into text than have the model listen to it directly, NobodyWho integrates Whisper models in ONNX format through SpeechToText . import ai.nobodywho.SpeechToText val stt = SpeechToText.load(source = "hf://onnx-community/whisper-base") val text = stt.transcribeFile("recording.mp3").completed() println(text) source is a Hugging Face repo (hf://owner/repo ) or a local directory laid out the same way. Browse the Whisper ONNX models on Hugging Face to find one that fits your accuracy and speed needs. If your audio comes from a buffer rather than a file, use transcribePcm : val text = stt.transcribePcm(samples, sampleRate = 16000u).completed() The buffer needs to be mono i16 PCM samples. The sample rate can be anything, NobodyWho resamples internally to what Whisper expects. And just like chat, transcription can be streamed piece by piece instead of waiting for the full result: stt.transcribeFile("recording.mp3").asFlow().collect { piece -> print(piece) } Text to Speech Going the other direction, TextToSpeech turns text into WAV audio you can play back or save. import ai.nobodywho.TextToSpeech import java.io.File import kotlinx.coroutines.runBlocking fun main() = runBlocking { val tts = TextToSpeech.load( source = "hf://NobodyWho/Kokoro-82M", voice = "bf_emma", language = "en-gb", ) val wav = tts.synthesize(text = "Hello from NobodyWho!") File("out.wav").writeBytes(wav) } Three architectures are supported, all ONNX-based: Kokoro, Pocket TTS, and Supertonic. NobodyWho infers which one you're using from the source string, so you only need to set architecture explicitly when loading from a custom local folder. Each architecture has its own voice and language options that need to agree with what the model supports. Voice Activity Detection Before transcribing audio, it helps to know when someone is actually speaking rather than relying on a fixed silence timeout. VoiceActivityDetection uses a small model to reliably tell speech and silence apart, and pairs naturally with SpeechToText . For streaming microphone input, push chunks in as they arrive: import ai.nobodywho.VoiceActivityDetection import ai.nobodywho.VoiceActivityDetectionEvent import ai.nobodywho.SpeechToText val vad = VoiceActivityDetection.load(sampleRate = 16000u, source = "hf://onnx-community/silero-vad") val stt = SpeechToText.load(source = "hf://onnx-community/whisper-base") while (true) { val chunk = readMic() if (vad.push(chunk) == VoiceActivityDetectionEvent.SPEECH_ENDED) break } val speech = vad.finish() val transcription = stt.transcribePcm(speech, sampleRate = 16000u).completed() println(transcription) Each push() call reports the current state (SPEECH_STARTED , SPEECH_ENDED , SPEECH , or SILENCE ), and finish() hands you back the buffered speech segment while resetting internal state for the next turn. If you already have a full recording and just want to pull out the speech segments from it, segment() does that in one pass: val audio = readWavPcm("recording.wav") for (speech in vad.segment(audio)) { val transcription = stt.transcribePcm(speech, sampleRate = 16000u).completed() println(transcription) } Sensitivity is tunable via threshold , minSpeechDurationMs , minSilenceDurationMs , and prerollDurationMs (how much audio to keep before the detected start, so you don't clip the beginning of a sentence). The defaults are a reasonable starting point, but VAD is one of those things that usually benefits from tuning to your actual environment. Tool Calling Tools let the model call out to real functions in your app rather than just generating text. NobodyWho uses Kotlin reflection to inspect a function's parameter names and types, so declaring a tool is as simple as passing a function reference: import ai.nobodywho.Tool fun getWeather(city: String, unit: String): String { return """{"temp": 22, "unit": "$unit"}""" } val weatherTool = Tool( name = "get_weather", description = "Get the current weather for a city", function = ::getWeather ) val chat = Chat.fromPath( modelPath = "hf://NobodyWho/Qwen_Qwen3-0.6B-GGUF/Qwen_Qwen3-0.6B-Q4_K_M.gguf", tools = listOf(weatherTool) ) Suspend functions work the same way, so a tool can make a network call without blocking the caller. Because it relies on reflection, the function has to be a top-level function, a class method, or a companion object method - local functions defined inside another function or coroutine aren't supported. Not every model supports tool calling well, the Qwen family is a solid choice if you need it to be reliable. See the Tool Calling documentation for more. RAG Retrieval-Augmented Generation combines document search with LLM generation,
Comments
No comments yet. Start the discussion.