TheStage Android SDK

Attention

Access to the Android SDK requires an API token from the TheStage AI Platform. Pricing is by arrangement — open a Service Request at app.thestage.ai/contact; there is no public rate card. See TheStage Android SDK Product Terms and Licensing & Device Identity.

License: LICENSE in the repository; commercial summary: TheStage Android SDK Product Terms

Overview

On-device speech, language and audio inference for Android on Qualcomm Snapdragon. The SDK runs compiled engines on the Hexagon NPU (HTP, via the QNN runtime) with automatic GPU / CPU fallback, pulls engines from HuggingFace on first use, and exposes a unified infer / infer_stream API for every pipeline. No server in the hot path.

There are two consumption surfaces: the native Kotlin singleton (TheStageAI) and the Flutter plugin (thestage_android_sdkTheStageFlutterSDK). They mirror each other one-to-one; Dart consumers always go through the JSON path.

Version

1.0.0 — pin this tag (ref: v1.0.0); do not float

Platform

Android, minSdk 28, compileSdk 35, arm64-v8a only

Backend

Hexagon NPU (QNN / HTP) with automatic GPU / CPU fallback

Token

one online check per process (cached in-memory); a fresh launch re-validates online

Engines ship per model through HuggingFace (or Google Play AI packs — see AI Packs (Google Play delivery)); each pipeline page lists its models in a Supported models table, and model cards (contracts + acknowledgments) live at huggingface.co/TheStageAI. An on-device chat-LLM pipeline (TheStageAI/Qwen3-0.6B, LFM2.5-230M) is coming soon.

Prerequisites

Requirement

Minimum

Notes

Android device

Android 9 (API 28)

physical Qualcomm Snapdragon, arm64-v8a

Android SDK (compile)

API 35

Flutter (only for the Flutter examples)

3.24

with a matching Dart 3.5+

JDK

17

The NPU backend requires a Snapdragon SoC with a Hexagon DSP; on other hardware the SDK falls back to GPU/CPU.

Obtaining an API Token

Every SDK call begins with initialize(api_token = ...). You need a valid API token from the TheStage AI Platform before any pipeline will load.

  1. Sign in at app.thestage.ai.

  2. Go to Profile → API tokens tab.

  3. Click Generate API token, add a description, and press Generate token.

  4. Copy the token immediately — you will not be able to view it again after leaving the page.

For the full walkthrough with screenshots, see SSH Keys and API Tokens.

Once you have a token, pass it to the SDK at startup:

Kotlin:

import ai.thestage.qlip.TheStageAI

TheStageAI.registerContext(context)
TheStageAI.initialize(api_token = "your-api-token")

Flutter:

import 'package:thestage_android_sdk/thestage_android_sdk.dart';

await TheStageFlutterSDK.initialize(api_token: 'your-api-token');

registerContext (Kotlin only) takes any Android Context — your Application, an Activity, or a Service. The SDK keeps only the applicationContext (no Activity leak) and uses it to reach app-private storage, preferences, and other platform services. Call it once per process, before initialize. Flutter apps never call it — the plugin registers the context automatically when the engine attaches.

The token is validated online once per process (cached in-memory); a fresh app launch re-validates online — there is no offline grace window, so the device must be reachable on first use. If initialize fails with Token validation failed, check the token and the network connection. A billable seat is the pair (apiToken, deviceId) — see Licensing & Device Identity for how devices are identified and counted (do not depend on how the device identity is derived).

Attention

Keep your API token secret. Never commit it to source control. For Flutter apps, use --dart-define-from-file=secrets.json and add secrets.json to .gitignore — see Secrets below.

Example Apps

The repository ships ready-to-run Flutter examples under examples/ — each has its own README.md with app-specific notes.

Example

Description

tts_front_stream

Streaming neural TTS demo. Start here.

voice_agent

Full voice-assistant loop, mic → VAD → STT → LLM → streaming TTS.

voice_agent_custom_nodes

The voice agent plus custom graph nodes + ephemeral VLM captions + screen recording.

engine_bench

On-device TTS / ASR / VLM benchmark with JSON export.

Alongside the examples, the repo contains TheStageCore.aar (the pre-built SDK binary: inference engine, license gate, ONNX Runtime / QNN / Genie runtimes — opaque; you link it, you don’t build it), onnxruntime-android.aar (the QNN-enabled ONNX Runtime AAR the core links against, supplied by the app at runtime), plugin/thestage_android_sdk/ (the Flutter plugin over platform channels; bundles the native SDK, nothing to build — Dart entrypoint plugin/thestage_android_sdk/lib/thestage_android_sdk.dart), docs/ (per-pipeline reference guides), scripts/setup.sh (one-time host setup: AAR symlinks + secrets bootstrap for the Flutter examples) and VERSION (the SDK line this checkout ships, 1.0.0).

To run an example:

# 1. One-time host setup: symlink the AARs into the plugin and
#    bootstrap each example's secrets.json. Idempotent.
./scripts/setup.sh

# 2. Drop your API keys into the example you want to run.
cp examples/tts_front_stream/secrets.example.json \
   examples/tts_front_stream/secrets.json
$EDITOR examples/tts_front_stream/secrets.json

# 3. Build & run on a connected Snapdragon device.
cd examples/tts_front_stream
flutter pub get
flutter run --release \
    --dart-define-from-file=secrets.json \
    -d <YOUR_DEVICE_ID>

flutter devices lists attached devices. examples/voice_agent follows the same recipe (it additionally needs OPENAI_API_KEY in its secrets.json).

Integration: Native Kotlin (Gradle)

You can consume the AARs directly from a Kotlin/Android app, no Flutter involved. Copy the two prebuilt AARs into your app module’s libs/.

The Qualcomm QNN runtime (the signed per-SoC HTP skel libraries the NPU backend needs) is published on Qualcomm’s public Maven repository — no login required. Declare that repository in your settings.gradle.kts alongside the usual ones:

// settings.gradle.kts
dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
        // Qualcomm QNN / QAIRT runtime artifacts (public, no auth).
        maven { url = uri("https://qpm-download.qualcomm.com/maven/release") }
    }
}

Then in your app module’s build.gradle.kts:

dependencies {
    // The precompiled TheStage core (opaque engine/license/runtime)
    // and the QNN-enabled ONNX Runtime AAR.
    implementation(files("libs/TheStageCore.aar"))
    implementation(files("libs/onnxruntime-android.aar"))

    // Qualcomm QNN runtime — 19 libs: libQnnHtp / HtpPrepare / System /
    // Gpu / Dsp plus the per-SoC HTP skel + stub libraries
    // (V68, V69, V73, V75, V79, V81, and Dsp V66). Resolved from the
    // Qualcomm Maven repo declared above.
    implementation("com.qualcomm.qti:qnn-runtime:2.42.0")

    // Transitive deps of the core (not pulled via files(...)).
    implementation("com.google.code.gson:gson:2.11.0")
    implementation("com.squareup.okhttp3:okhttp:4.12.0")
    implementation(
        "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0"
    )
}

Warning

The qnn-runtime Maven artifact does not contain the Genie backend libraries. If you use any Genie/Stagenie-backed pipeline (the on-device LLM and NeuTTS Genie paths), you must additionally copy four QAIRT runtime libs from a Qualcomm QAIRT SDK install — see Qualcomm QAIRT Runtime Libs (Genie Backend) below.

Because both TheStageCore.aar and onnxruntime-android.aar bundle native .so libraries, add a packaging block to your app module so the merge picks one copy of each duplicate, and restrict the ABI to arm64-v8a:

android {
    defaultConfig {
        minSdk = 28
        ndk { abiFilters += "arm64-v8a" }
    }
    packaging {
        jniLibs.pickFirsts.add("lib/arm64-v8a/libonnxruntime.so")
        jniLibs.pickFirsts.add("lib/arm64-v8a/libonnxruntime4j_jni.so")
        jniLibs.pickFirsts.add("lib/arm64-v8a/libc++_shared.so")
    }
}

QNN’s FastRPC skel libraries load from an APK only when they are extracted at install time, so set android:extractNativeLibs="true" on your <application> (or useLegacyPackaging = true in the jniLibs packaging block). Then drive the singleton from Kotlin — register the app Context once, then initialize with your token:

// Once, at app start (e.g. Application.onCreate).
TheStageAI.registerContext(context)

// Suspend — validates the token online (once per process).
TheStageAI.initialize(api_token = "th_…")

TheStageAI.start_model(
    model_name = "stt",
    engines_path = "TheStageAI/thewhisper-large-v3-turbo",
)

val result = TheStageAI.infer(
    model_name = "stt",
    input_json = mapOf(
        "audio" to pcm_16k_mono,   // FloatArray, mono, [-1, 1]
        "language" to "en",
    ),
)

registerContext is native-only (the Flutter plugin registers the Context for you). initialize, start_model, infer and infer_stream are suspend functions — call them from a coroutine.

Qualcomm QAIRT Runtime Libs (Genie Backend)

The Genie/Stagenie backend that drives the on-device LLM and the NeuTTS Genie path depends on four native libraries that are not in the com.qualcomm.qti:qnn-runtime Maven artifact and that this SDK does not redistribute:

Library

Role

libQnnGenAiTransformer.so

GenAI transformer backend

libQnnGenAiTransformerCpuOpPkg.so

GenAI transformer CPU op package

libQnnGenAiTransformerModel.so

GenAI transformer model backend

libQnnCpu.so

QNN CPU fallback backend

The Genie generation runtime itself is not in this list — it ships as libStagenie.so inside TheStageCore.aar (a patched Genie build). You do not need stock libGenie.so from QAIRT.

You must obtain these from a Qualcomm QAIRT SDK 2.42 install (e.g. ~/Qualcomm/AIStack/QAIRT/2.42.0.251225, under lib/aarch64-android/) and copy them into your app’s — or the plugin’s — jniLibs/arm64-v8a/. The bundled scripts/setup.sh does this for you when the QAIRT env var points at your install:

QAIRT=~/Qualcomm/AIStack/QAIRT/2.42.0.251225 ./scripts/setup.sh

Getting the QAIRT SDK: download the Qualcomm AI Runtime SDK (QAIRT — the Qualcomm AI Engine Direct / QNN runtime), version 2.42.0, from Qualcomm’s developer site via Qualcomm Package Manager (qpm.qualcomm.com — a free Qualcomm account is required; search for “Qualcomm AI Runtime SDK”). Install it, then set QAIRT to the versioned install directory (e.g. ~/Qualcomm/AIStack/QAIRT/2.42.0.251225) and re-run setup.sh; the libs it copies live under $QAIRT/lib/aarch64-android/.

Version 2.42.0 is required — the shipped libStagenie.so inside TheStageCore.aar is built against QAIRT 2.42.0 and is coupled to that runtime. Other QAIRT versions produce context binaries / ABI the shipped runtime rejects. Without these libs, any Genie pipeline crashes at first use with dlopen failed: library "libQnnGenAiTransformer.so" not found.

The Snapdragon-only QNN NPU backend (the qnn-runtime Maven artifact above) is what everything else uses; only the Genie pipelines need this extra QAIRT copy step.

Integration: Flutter Plugin

Add the plugin as a git: dependency in your app’s pubspec.yaml, pinned to the tag:

dependencies:
  thestage_android_sdk:
    git:
      url: https://github.com/TheStageAI/AndroidSDK.git
      path: plugin/thestage_android_sdk
      ref: v1.0.0

The plugin declares the prebuilt AARs compileOnly, so your app module must supply them at runtime. Copy TheStageCore.aar and the ONNX Runtime AAR into your app’s libs/ and wire them up as in the Integration: Native Kotlin (Gradle) section above, then:

import 'package:thestage_android_sdk/thestage_android_sdk.dart';

await TheStageFlutterSDK.initialize(api_token: 'th_…');

await TheStageFlutterSDK.start_model(
  model_name: 'stt',
  engines_path: 'TheStageAI/thewhisper-large-v3-turbo',
);

final result = await TheStageFlutterSDK.infer(
  model_name: 'stt',
  input_json: {
    'audio': pcm_16k_mono,   // Float32List, mono, [-1, 1]
    'language': 'en',
  },
);
print(result[0]['transcription']);

The fastest way to see a real app is to copy one of the examples/ apps.

Kotlin / Flutter Parity

The Kotlin singleton (TheStageAI) and the Flutter TheStageFlutterSDK mirror each other one-to-one. Dart consumers always go through the JSON path.

Operation

Kotlin

Flutter (Dart)

Initialize

TheStageAI.registerContext(context) then TheStageAI.initialize(api_token = "...")

await TheStageFlutterSDK.initialize(api_token: '...')

Start a model

TheStageAI.start_model(model_name =, engines_path =, config =)

await TheStageFlutterSDK.start_model(model_name:, engines_path:, config:)

Stop a model

TheStageAI.stop_model(model_name = "tts")

await TheStageFlutterSDK.stop_model(model_name: 'tts')

Single-shot inference

TheStageAI.infer(model_name =, input_json =) -> List<Map<String, Any>>

await TheStageFlutterSDK.infer(model_name:, input_json:) -> List<Map<String, dynamic>>

Streaming inference

TheStageAI.infer_stream(model_name =, input_json =) -> Flow<Map<String, Any>>

TheStageFlutterSDK.infer_stream(model_name:, input_json:, stream_id:?) -> Stream<Map<String, dynamic>>

Push text into a TTS stream

TheStageAI.send(stream_id =, text =); TheStageAI.finish_stream(stream_id =)

await TheStageFlutterSDK.send(stream_id:, text:); await TheStageFlutterSDK.finish_stream(stream_id:)

Cancel a running stream

TheStageAI.stop_stream(stream_id =)

await TheStageFlutterSDK.stop_stream(stream_id:)

Load progress

config progress callback / on_progress flow

Single global stream TheStageFlutterSDK.on_progress ({model_name, phase, progress})

Audio buffer type

FloatArray

Float32List (never Float64List)

Both surfaces use the same model_name strings and the same input_json shape; the Flutter path is JSON-only.

Load Progress

All loaders emit progress through four phases with a monotonic fraction in 0…1:

Phase

Fraction band

Notes

downloading

0.00 – 0.70

HuggingFace repo download (skipped on cache hit)

extracting

0.70 – 0.85

Bundle unpack to local cache (skipped on cache hit)

loading

0.85 – 0.99

Pipeline construction

ready

1.00 (terminal)

Emitted on success only

Flutter — events from every active start_model are multiplexed through one global Stream. Filter by model_name to disambiguate concurrent loads:

TheStageFlutterSDK.on_progress.listen((event) {
  // event['model_name'] : String  — model handle passed to start_model
  // event['phase']      : String  — 'downloading' | 'extracting' | 'loading' | 'ready'
  // event['progress']   : double  — 0.0 … 1.0, monotonic
});

A complete first-run example — subscribe before start_model so the download / extract / load phases are reported as the engines arrive:

import 'package:thestage_android_sdk/thestage_android_sdk.dart';

await TheStageFlutterSDK.initialize(api_token: 'your-api-token');

// Subscribe to load progress for any model_name.
TheStageFlutterSDK.on_progress.listen((event) {
  if (event['model_name'] != 'stt') return;
  print('[stt] ${event['phase']} ${(event['progress'] as double) * 100}%');
});

await TheStageFlutterSDK.start_model(
  model_name: 'stt',
  engines_path: 'TheStageAI/thewhisper-large-v3-turbo',
);

final result = await TheStageFlutterSDK.infer(
  model_name: 'stt',
  input_json: {
    'audio': pcm_16k_mono,   // Float32List, mono, [-1, 1]
    'language': 'en',
  },
);
print(result[0]['transcription']);

The phase strings, fraction bands and terminal contract are identical on both surfaces. See the per-model guides (e.g. TTS (NeuTTS)) for the full event contract.

Audio I/O Contract

All audio crossing the public SDK surface — VAD input, Whisper input, NeuTTS output — uses PCM float, mono, samples normalized to [-1.0, 1.0]. On Kotlin this is FloatArray; the Flutter plugin marshals the same data as Float32List (never Float64List). Sample rate depends on the pipeline:

Pipeline

Direction

Sample rate

Frame / chunking

Notes

Silero VAD

input

16 000 Hz

exactly 512 samples per call (32 ms)

Stateful LSTM. Reset state between independent utterances; the model keeps a 64-sample internal carry-over, so you don’t overlap chunks yourself.

Whisper

input

16 000 Hz

any length

Long audio is auto-split into bundle-defined windows (the shipping thewhisper-large-v3-turbo bundle uses 10 s windows).

NeuTTS

output

24 000 Hz

streamer emits per-sentence chunks (variable length); batch mode emits one buffer of full duration

NeuCodec drives the rate (hop=320, token_rate=50).

Speaker ID

input

16 000 Hz

2.0 s window (pad / trim)

Practical consequences:

  • The mic stack must run at 16 kHz mono Float for VAD and ASR.

  • TTS output is always 24 kHz, even though VAD/ASR are 16 kHz. Resample TTS to 16 kHz or drive the player at 24 kHz if you route everything through one playback path.

Secrets

The Flutter example apps read tokens at build time via String.fromEnvironment(...) and --dart-define-from-file=secrets.json. Each ships a secrets.example.json template — copy it to secrets.json and fill in your keys. secrets.json is covered by .gitignore; real keys never belong in source.

Also see

  • ASR (Whisper) — Whisper speech-to-text with automatic VAD chunking and long-audio stitching

  • VLM (Vision-Language) — on-device image + prompt → text (LFM2.5-VL), batch and streaming

  • TTS (NeuTTS) — multilingual (9 languages) and nano (English) neural TTS with batch + push-based streaming

  • VAD (Voice Activity Detection) — Silero VAD: stateful per-chunk speech detection

  • Streaming — TTS / LLM streaming patterns, back-pressure, sentence segmentation, Flutter consumers

  • Voice Agent — end-to-end voice assistant (VAD → STT → LLM → TTS) with neural end-of-turn detection and barge-in

  • Model Management — lifecycle, availability probing, prefetch, revisions, the on-device model cache, per-component load/unload, bundled-engine paths, process-memory reporting

  • AI Packs (Google Play delivery) — ship models via Google Play AI packs (aipack:// engines source): pack modules, device targeting, delivery modes, availability

  • Speaker Embedding — ReDimNet2 speaker-id: enroll + cosine verification, and voice-agent speaker gating

  • Licensing & Device Identity — how (apiToken, deviceId) is derived on Android, what counts as a device, reinstall behavior, device-integrity enforcement, online-validation behavior

  • TheStage Android SDK Product Terms — commercial / licensing summary: seat model and pricing via Service Request (no public rate card)

  • Logging & Diagnostics — the diagnostics ring, session log file, and the Flutter logs stream (no user content, sanitized paths)