Android Essence 2
A complete Kotlin app with a photoreal Essence 2 avatar on an Android phone.
Capture details
Captured on Samsung Galaxy S25+ (Android) · essence2-android 0.5.14 · sofia-ramirez (Essence 2) · 2026-09-23.The app downloads the sofia-ramirez sample avatar once, renders every frame of a speech clip on the phone, then plays the audio with the frames in sync. Tap the screen to replay.
Requirements
| You need | Notes |
|---|---|
A physical arm64-v8a Android phone, USB debugging on | emulators cannot load the engine |
| JDK 17 and an Android SDK with platform 35 | the project pins Gradle 8.11.1 and Android Gradle Plugin 8.7.3 |
adb on your PATH | it ships in $ANDROID_ |
| An API secret | the engine bills session time, talking or idle |
Get the code
git clone https://github.com/bithuman-product/bithuman-examples.git
cd bithuman-examples/android/essence2-hello
Set your API secret
Write local.properties next to settings.gradle.kts. The file is git-ignored.
sdk.dir=/path/to/your/Android/sdk
bithuman.apiSecret=<your API secret>
BITHUMAN_API_SECRET in the environment works instead of the second line.
Run it
./gradlew :app:assembleDebug && adb install -r app/build/outputs/apk/debug/app-debug.apk
adb shell am start -n com.example.e2hello/.MainActivity
The first launch renders the 16 kHz clip bundled in the app. To use your own audio, push any 16-bit PCM WAV (the app converts it to 16 kHz mono) and restart:
adb push my.wav /storage/emulated/0/Android/data/com.example.e2hello/files/speech.wav && adb shell am start -S -n com.example.e2hello/.MainActivity
Expected output
adb logcat -s E2HELLO shows the download, then:
engine: 1080x1920 targetFrames=251
rendered 347 frames in 13 s — playing…
347 frames, 13.87 s — tap to replay
347 frames for 13.87 seconds of audio is 25 fps at the avatar’s own 1080×1920. The first run downloads about 240 MB; later runs start at the engine.
How it works
MainActivity.kt does four things, all from ai.bithuman:essence2-android:
- sets your API secret once with
Essence2Credential.set(BuildConfig.BITHUMAN_API_SECRET), which covers the download and the session; - downloads the avatar with
Essence2ModelStore(this).fetch(agentCode)(A52DHS2219by default); - opens it with
Essence2Avatar.create(identity.dir)on a background thread; - calls
feed(pcm)andendOfAudio(), thenpull(frame)until every frame is out: one frame per 40 ms of audio.
The calls and the live-streaming loop are on Android.
The code that matters
The render path of MainActivity.kt, as it is in the repository: the credential, the download, then feed and pull until the utterance is out.
// excerpt: android/essence2-hello/app/src/main/java/com/example/e2hello/MainActivity.kt
// 1. The CREDENTIAL, once, before anything downloads or opens an engine.
// One setter covers the store's download and the engine's meter;
// create() refuses without it.
Essence2Credential.set(secret)
// …
// 2. The STORE downloads with the credential set above.
// Blocks on the network the first time; that is why this is a worker thread.
val store = Essence2ModelStore(this)
val identity = store.fetch(agentCode, progress = { member, done, total ->
if (done == total) Log.i(TAG, "fetched $member ($total B)")
})
// …
Essence2Avatar.create(identity.dir).use { avatar ->
// …
val frame = avatar.newFrameBuffer() // direct, width * height * 4, RGBA
// …
avatar.feed(pcm) // 16-bit little-endian PCM bytes, as read
avatar.endOfAudio() // "that is the whole utterance"
// …
// pull() returns false until frames are ready, so poll. Stop after 5 s
// with no new frame — but give the FIRST frame longer (30 s): a cold
// first render on a slower phone can take more than a few seconds, and
// giving up before it arrives is a blank screen, not a result.
var quietMs = 0
while (quietMs < (if (out.isEmpty()) 30_000 else 5_000)) {
frame.clear()
if (avatar.pull(frame)) { // true = a frame was written
// …
quietMs = 0
frame.rewind()
// …
// A render that failed must not reach you as silence: checkRender()
// turns a zero-frame render into a throw that names the reason.
avatar.checkRender()
The complete file is on GitHub.
Make it your own
-
Your own avatar: create one with the Agents API with
"model": "essence-2", then pass its agent code tofetch; the secret you set withEssence2Credential.setdownloads it. -
Live speech: feed 16 kHz mono 16-bit audio as it arrives and pull frames at 25 fps; call
endOfAudio()at the end of each reply andidle(buffer)between replies. For the microphone, declare and requestRECORD_AUDIOfirst (Build an Android app). -
Ship it: the secret in
BuildConfigcan be read out of the APK. A real app fetches it from your backend at startup and passes it toEssence2Credential.set. -
Zero-copy frames (essence2-android 0.7.0 and newer):
useHardwareBuffers()switches delivery to zero-copy:pullHardwareBuffer()andidleHardwareBuffer()return anEssence2HardwareFramewhose RGBAHardwareBufferyour renderer samples directly. Close each frame after presenting it.pull(ByteBuffer)is unchanged, and nothing changes until you calluseHardwareBuffers()(Android API).val avatar = Essence2Avatar.create(bundleDir) avatar.useHardwareBuffers() // once, before the first frame avatar.feed(pcm) avatar.pullHardwareBuffer()?.use { frame -> renderer.draw(frame.buffer) // e.g. an EGLImage / Vulkan import, or Bitmap.wrapHardwareBuffer }
Troubleshooting
| Symptom | Fix |
|---|---|
Essence2MeteringRefused (ai. in stack traces) on the first pull() | set bithuman. in local., rebuild |
Manifest merge fails on minSdk | Essence 2 needs minSdk 29; the example already sets it |
UnsatisfiedLinkError | run on a physical arm64 phone, not an emulator |
| The build refuses the JDK | use JDK 17 (java -version) |
More on Android: Troubleshooting.
