Add native Android app scaffold (M0): whisper.cpp engine via JNI

- android/: Gradle/Kotlin project (AGP 9.4, Compose, NDK 27.1), monorepo
  subdir as planned; whisper.cpp v1.9.3 vendored via pinned fetch script
- core/whisper: JNI wrapper (beam size, threads, language) + WhisperEngine
  Kotlin API mirroring the desktop engine contract
- app (M0 scope): in-app GGML model download from Hugging Face, engine
  load, WAV picker with resampling, on-device transcription, share sheet
- build validated: assembleDebug OK, libwhisper_jni.so + ggml packaged
This commit is contained in:
2026-09-07 11:00:31 +02:00
parent 66408e291d
commit 404f3db200
22 changed files with 1273 additions and 2 deletions
+11
View File
@@ -0,0 +1,11 @@
# Gradle / IDE
.gradle/
build/
.cxx/
local.properties
.idea/
*.iml
.kotlin/
# Vendored whisper.cpp sources (run ./tools/fetch-whisper.sh)
third_party/
+62
View File
@@ -0,0 +1,62 @@
# MeetRec for Android
Native Android app with the same functionality as the desktop meetrec:
record meetings and transcribe them on-device with Whisper. Nothing leaves
the phone — no cloud, no telemetry.
Status: **M0 (engine proof)** — the app can download a GGML model, load it
via JNI (whisper.cpp, CPU/NEON) and transcribe a picked WAV file. Recording,
live transcript, and output files arrive in later milestones (see the
milestone plan in the project docs).
## Requirements
- Android Studio (or: SDK Platform 36, Build Tools 36, NDK 27.1, CMake 3.22.1)
- JDK 17+
- Device running Android 10+ (developed against a Fairphone 6 / Snapdragon
7s Gen 3, Android 15+)
## Build
```sh
cd android
./tools/fetch-whisper.sh # vendors whisper.cpp v1.9.3 into third_party/
./gradlew :app:assembleDebug # or open the android/ folder in Android Studio
adb install -r app/build/outputs/apk/debug/app-debug.apk
```
`third_party/whisper.cpp` is gitignored — the fetch script pins the exact
release tag so the JNI layer never breaks on upstream churn.
## Test on device (M0)
1. Launch **MeetRec**, pick a model (`tiny` is fine for a first test) and tap
**Download** (model comes from Hugging Face; tiny is ~75 MB).
2. Tap **Load engine**.
3. Tap **Pick WAV file** and choose a 16 kHz mono WAV for best results
(other PCM WAVs are resampled automatically).
4. Tap **Transcribe** — segments with timestamps appear; share via the
share sheet.
Expect roughly realtime transcription with `tiny`/`base` on the
Fairphone 6's CPU; `small` is noticeably slower — use it for final passes
only (the live/final split comes with the recorder milestones).
## Performance notes
- The engine runs on CPU via NEON, using up to 4 threads.
- GPU/NPU acceleration (e.g. Snapdragon NPU via the QNN backend) is a
stretch goal, not wired up yet.
## Module layout
```
app/ Compose UI (model download, WAV picker, transcript view)
core/whisper/ whisper.cpp JNI wrapper: CMake build + LibWhisper.kt +
WhisperEngine.kt (the on-device transcription API)
tools/ fetch-whisper.sh — vendor the pinned whisper.cpp release
```
Models live in the app's private storage (`filesDir/models`), shared files
with the desktop app's `~/.cache/meetrec/whisper-cpp` naming
(ggml-tiny.bin … ggml-large-v3.bin).
+43
View File
@@ -0,0 +1,43 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "com.meetrec.android"
compileSdk = 36
defaultConfig {
applicationId = "com.meetrec.android"
minSdk = 29
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
}
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(project(":core:whisper"))
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.material3)
implementation(libs.kotlinx.coroutines.android)
debugImplementation(libs.androidx.compose.ui.tooling.preview)
}
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Only used to download Whisper GGML models from Hugging Face. -->
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="MeetRec"
android:theme="@android:style/Theme.Material.Light.NoActionBar"
android:allowBackup="true">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,242 @@
package com.meetrec.android
import android.content.Intent
import android.os.Bundle
import android.provider.OpenableColumns
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.meetrec.core.whisper.WhisperEngine
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
/**
* M0 screen: proves the whisper.cpp engine on device.
* Pick a model (downloads from Hugging Face), load it, pick a WAV file,
* transcribe, share the text. Recording arrives in M1.
*/
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { MeetRecScreen() }
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MeetRecScreen() {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var model by remember { mutableStateOf("tiny") }
var modelMenu by remember { mutableStateOf(false) }
var language by remember { mutableStateOf("") }
var status by remember { mutableStateOf("Select a model, download and load it.") }
var busy by remember { mutableStateOf(false) }
var engine by remember { mutableStateOf<WhisperEngine?>(null) }
var wavName by remember { mutableStateOf<String?>(null) }
var wavSamples by remember { mutableStateOf<FloatArray?>(null) }
var segments by remember { mutableStateOf<List<WhisperEngine.Segment>>(emptyList()) }
val modelsDir = remember { File(context.filesDir, "models") }
val modelFile = ModelDownloader.modelFile(model, modelsDir)
fun <T> runBusy(block: suspend () -> T) {
if (busy) return
busy = true
scope.launch {
try {
block()
} catch (e: Exception) {
status = "Failed: ${e.message}"
} finally {
busy = false
}
}
}
val pickWav = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri ->
if (uri == null) return@rememberLauncherForActivityResult
runBusy {
val name = context.contentResolver
.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
?.use { c -> if (c.moveToFirst()) c.getString(0) else "audio" }
?: "audio"
val samples = withContext(Dispatchers.IO) {
context.contentResolver.openInputStream(uri)!!.use { WavReader.read(it) }
}
wavName = name
wavSamples = samples
segments = emptyList()
status = "$name: ${samples.size / WavReader.TARGET_RATE} s @ 16 kHz"
}
}
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text("MeetRec", style = MaterialTheme.typography.headlineSmall)
// model + language
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
ExposedDropdownMenuBox(
expanded = modelMenu,
onExpandedChange = { modelMenu = it },
modifier = Modifier.weight(1f),
) {
OutlinedTextField(
value = model,
onValueChange = {},
readOnly = true,
label = { Text("Model") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(modelMenu) },
modifier = Modifier.menuAnchor().fillMaxWidth(),
)
ExposedDropdownMenu(expanded = modelMenu, onDismissRequest = { modelMenu = false }) {
ModelDownloader.MODELS.keys.forEach { name ->
DropdownMenuItem(
text = { Text(name) },
onClick = { model = name; modelMenu = false },
)
}
}
}
OutlinedTextField(
value = language,
onValueChange = { language = it.trim() },
label = { Text("Language") },
placeholder = { Text("auto") },
modifier = Modifier.weight(1f),
)
}
// model actions
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = {
runBusy {
ModelDownloader.download(model, modelsDir) { done, total ->
status = if (total > 0) "Downloading $model: $done / $total bytes"
else "Downloading $model: $done bytes"
}
status = "Model $model ready at ${modelFile.name}"
}
},
enabled = !busy && !modelFile.isFile,
) { Text("Download") }
Button(
onClick = {
runBusy {
engine?.close()
withContext(Dispatchers.Default) {
engine = WhisperEngine.load(modelFile.absolutePath)
}
status = "$model loaded (${WhisperEngine.DEFAULT_THREADS} threads)"
}
},
enabled = !busy && modelFile.isFile,
) { Text("Load engine") }
}
// wav + transcribe actions
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedButton(
onClick = { pickWav.launch(arrayOf("audio/*", "application/octet-stream")) },
enabled = !busy,
) { Text(if (wavName == null) "Pick WAV file" else "Pick another WAV") }
Button(
onClick = {
val samples = wavSamples
val eng = engine
if (samples != null && eng != null) {
runBusy {
val start = System.currentTimeMillis()
val segs = withContext(Dispatchers.Default) {
eng.transcribe(
samples,
beamSize = 5,
language = language.ifBlank { null },
)
}
segments = segs
val secs = (System.currentTimeMillis() - start) / 1000.0
status = "${segs.size} segments in $secs s (audio " +
"${samples.size / WavReader.TARGET_RATE} s)"
}
}
},
enabled = !busy && engine != null && wavSamples != null,
) { Text("Transcribe") }
}
if (segments.isNotEmpty()) {
OutlinedButton(onClick = {
val text = segments.joinToString("\n") {
"[${fmtMs(it.startMs)}] ${it.text}"
}
context.startActivity(
Intent.createChooser(
Intent(Intent.ACTION_SEND)
.setType("text/plain")
.putExtra(Intent.EXTRA_TEXT, text),
"Share transcript",
)
)
}) { Text("Share transcript") }
}
Text(status, style = MaterialTheme.typography.bodySmall)
if (busy) CircularProgressIndicator()
LazyColumn(modifier = Modifier.fillMaxWidth().weight(1f)) {
items(segments) { seg ->
Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Text(
"${fmtMs(seg.startMs)} ${fmtMs(seg.endMs)}",
style = MaterialTheme.typography.labelSmall,
)
Text(seg.text, style = MaterialTheme.typography.bodyMedium)
}
}
}
}
}
private fun fmtMs(ms: Long): String {
val total = ms / 1000
return "%02d:%02d".format(total / 60, total % 60)
}
@@ -0,0 +1,73 @@
package com.meetrec.android
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.BufferedOutputStream
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
/**
* Downloads Whisper GGML models from Hugging Face into app-private storage.
* Same files the desktop app uses, so both sides share one model ecosystem.
*/
object ModelDownloader {
val MODELS = linkedMapOf(
"tiny" to "ggml-tiny.bin",
"base" to "ggml-base.bin",
"small" to "ggml-small.bin",
"medium" to "ggml-medium.bin",
"large-v3" to "ggml-large-v3.bin",
)
private const val BASE_URL = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/"
fun modelFile(modelName: String, dir: File): File = File(dir, MODELS.getValue(modelName))
fun isDownloaded(modelName: String, dir: File): Boolean =
modelFile(modelName, dir).let { it.isFile && it.length() > 0 }
/**
* Downloads [modelName] into [dir] (no-op if already present).
* [onProgress] gets (bytesDone, totalBytes-or-0). Throws on failure.
*/
suspend fun download(
modelName: String,
dir: File,
onProgress: suspend (Long, Long) -> Unit = { _, _ -> },
): File = withContext(Dispatchers.IO) {
val dest = modelFile(modelName, dir)
if (isDownloaded(modelName, dir)) return@withContext dest
dir.mkdirs()
val tmp = File(dir, dest.name + ".part")
val conn = URL(BASE_URL + MODELS.getValue(modelName))
.openConnection() as HttpURLConnection
conn.connectTimeout = 15_000
conn.readTimeout = 60_000
conn.instanceFollowRedirects = true
conn.setRequestProperty("User-Agent", "meetrec-android")
try {
val total = conn.contentLengthLong
conn.inputStream.use { ins ->
BufferedOutputStream(FileOutputStream(tmp)).use { out ->
val buf = ByteArray(64 * 1024)
var done = 0L
while (true) {
val r = ins.read(buf)
if (r < 0) break
out.write(buf, 0, r)
done += r
onProgress(done, if (total > 0) total else 0L)
}
}
}
} finally {
conn.disconnect()
}
if (!tmp.renameTo(dest)) error("could not finalize model download")
dest
}
}
@@ -0,0 +1,107 @@
package com.meetrec.android
import java.io.InputStream
/**
* Reads a PCM WAV file into 16 kHz mono float32 — the format Whisper wants.
*
* Supports 16-bit PCM and 32-bit IEEE-float WAVs of any channel count and
* sample rate (multi-channel is averaged, rates are linearly resampled).
* Pure Kotlin so it is unit-testable on the JVM.
*/
object WavReader {
const val TARGET_RATE = 16000
fun read(input: InputStream): FloatArray {
val data = input.readBytes()
require(data.size > 44) { "file too small to be a WAV" }
require(String(data, 0, 4) == "RIFF" && String(data, 8, 4) == "WAVE") {
"not a RIFF/WAVE file"
}
var channels = 0
var sampleRate = 0
var audioFormat = 0
var bitsPerSample = 0
var samples: FloatArray? = null
var pos = 12
while (pos + 8 <= data.size) {
val id = String(data, pos, 4)
val size = leInt(data, pos + 4)
val body = pos + 8
when (id) {
"fmt " -> {
audioFormat = leInt(data, body)
channels = leInt(data, body + 2)
sampleRate = leInt(data, body + 4)
bitsPerSample = leInt(data, body + 14)
}
"data" -> {
val fmt = audioFormat
require(fmt == 1 || fmt == 3) {
"unsupported WAV audio format $fmt (need PCM or IEEE float)"
}
require(bitsPerSample == 16 || bitsPerSample == 32) {
"unsupported bit depth $bitsPerSample (need 16 or 32)"
}
require(channels > 0 && sampleRate > 0) { "malformed fmt chunk" }
val len = minOf(size, data.size - body)
samples = decode(data, body, len, fmt, bitsPerSample, channels)
}
}
pos = body + size + (size and 1) // chunks are word-aligned
}
val mono = samples ?: error("no data chunk found")
return if (sampleRate == TARGET_RATE) mono else resample(mono, sampleRate, TARGET_RATE)
}
private fun decode(
data: ByteArray, body: Int, len: Int,
audioFormat: Int, bits: Int, channels: Int,
): FloatArray {
val bytesPerSample = bits / 8
val frames = len / (bytesPerSample * channels)
val out = FloatArray(frames)
for (f in 0 until frames) {
var acc = 0f
for (c in 0 until channels) {
val o = body + (f * channels + c) * bytesPerSample
acc += when {
audioFormat == 1 && bits == 16 ->
((data[o].toInt() and 0xFF) or (data[o + 1].toInt() shl 8)) / 32768f
audioFormat == 1 && bits == 32 ->
leInt(data, o) / 2147483648f
audioFormat == 3 && bits == 32 ->
Float.fromBits(leInt(data, o))
else -> 0f
}
}
out[f] = acc / channels
}
return out
}
private fun resample(x: FloatArray, from: Int, to: Int): FloatArray {
if (x.isEmpty()) return x
val n = (x.size.toDouble() * to / from).toInt().coerceAtLeast(1)
val out = FloatArray(n)
val step = from.toDouble() / to
for (i in 0 until n) {
val t = i * step
val i0 = t.toInt().coerceAtMost(x.size - 1)
val i1 = (i0 + 1).coerceAtMost(x.size - 1)
val frac = (t - i0).toFloat()
out[i] = x[i0] * (1f - frac) + x[i1] * frac
}
return out
}
private fun leInt(b: ByteArray, off: Int): Int =
(b[off].toInt() and 0xFF) or
((b[off + 1].toInt() and 0xFF) shl 8) or
((b[off + 2].toInt() and 0xFF) shl 16) or
((b[off + 3].toInt() and 0xFF) shl 24)
}
+6
View File
@@ -0,0 +1,6 @@
// Root build file — plugin versions come from gradle/libs.versions.toml.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.compose) apply false
}
+30
View File
@@ -0,0 +1,30 @@
plugins {
alias(libs.plugins.android.library)
}
android {
namespace = "com.meetrec.core.whisper"
compileSdk = 36
defaultConfig {
minSdk = 29
ndk {
// Fairphone 6 (and virtually all current phones) are arm64.
abiFilters += "arm64-v8a"
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
version = "3.22.1"
}
}
ndkVersion = "27.1.12297006"
}
@@ -0,0 +1,57 @@
# MeetRec whisper JNI — builds whisper.cpp (CPU backend) for Android.
#
# Mirrors the approach of whisper.cpp's own Android example: compile
# src/whisper.cpp directly and build ggml via FetchContent, instead of
# add_subdirectory-ing the full whisper.cpp CMake tree.
cmake_minimum_required(VERSION 3.22)
project(meetrec_whisper LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 17)
set(WHISPER_CPP "${CMAKE_CURRENT_SOURCE_DIR}/../../../../../third_party/whisper.cpp")
if(NOT EXISTS "${WHISPER_CPP}/src/whisper.cpp")
message(FATAL_ERROR
"whisper.cpp sources not found at ${WHISPER_CPP}. "
"Run ./tools/fetch-whisper.sh from the android/ directory first.")
endif()
set(SOURCE_FILES
${WHISPER_CPP}/src/whisper.cpp
${CMAKE_CURRENT_SOURCE_DIR}/whisper_jni.cpp)
find_library(LOG_LIB log)
include(FetchContent)
FetchContent_Declare(ggml SOURCE_DIR ${WHISPER_CPP}/ggml)
FetchContent_MakeAvailable(ggml)
add_library(whisper_jni SHARED ${SOURCE_FILES})
# whisper.cpp's src/whisper.cpp returns WHISPER_VERSION from
# whisper_print_system_info(); its own CMake normally defines it.
file(READ "${WHISPER_CPP}/CMakeLists.txt" _WC_MAIN)
if(_WC_MAIN MATCHES
"set\\(WHISPER_VERSION_MAJOR ([0-9]+)\\).*\nset\\(WHISPER_VERSION_MINOR ([0-9]+)\\).*\nset\\(WHISPER_VERSION_PATCH ([0-9]+)\\)")
set(WHISPER_VERSION "${CMAKE_MATCH_1}.${CMAKE_MATCH_2}.${CMAKE_MATCH_3}")
else()
set(WHISPER_VERSION "unknown")
endif()
message(STATUS "whisper.cpp version: ${WHISPER_VERSION}")
target_compile_definitions(whisper_jni PRIVATE WHISPER_VERSION="${WHISPER_VERSION}")
# CPU (NEON) backend only for now; GPU/QNN acceleration is a stretch goal.
target_compile_definitions(whisper_jni PUBLIC GGML_USE_CPU)
if(NOT CMAKE_BUILD_TYPE STREQUAL "Debug")
target_compile_options(whisper_jni PRIVATE -O3 -fvisibility=hidden)
target_link_options(whisper_jni PRIVATE
-Wl,--gc-sections -Wl,--exclude-libs,ALL -flto)
endif()
target_include_directories(whisper_jni PRIVATE
${WHISPER_CPP}/include
${WHISPER_CPP}/src
${WHISPER_CPP}/ggml/include)
target_link_libraries(whisper_jni PRIVATE ${LOG_LIB} android ggml)
@@ -0,0 +1,103 @@
// whisper_jni.cpp — JNI bridge between WhisperEngine.kt and whisper.cpp.
//
// Extended from whisper.cpp's Android example (examples/whisper.android):
// adds beam-size, thread-count and language parameters, which the stock
// example wrapper does not expose.
#include <android/log.h>
#include <jni.h>
#include <string.h>
#include "whisper.h"
#define TAG "meetrec-whisper"
#define LOGI( ... ) __android_log_print ( ANDROID_LOG_INFO, TAG, __VA_ARGS__ )
namespace {
whisper_context *as_ctx ( jlong ptr ) { return reinterpret_cast<whisper_context *> ( ptr ); }
} // namespace
extern "C" {
JNIEXPORT jlong JNICALL Java_com_meetrec_core_whisper_LibWhisper_initContext ( JNIEnv *env, jobject,
jstring model_path ) {
const char *path = env->GetStringUTFChars ( model_path, nullptr );
struct whisper_context_params cparams = whisper_context_default_params();
cparams.use_gpu = false; // CPU (NEON) build; GPU is a stretch goal
whisper_context *ctx = whisper_init_from_file_with_params ( path, cparams );
env->ReleaseStringUTFChars ( model_path, path );
if ( ctx == nullptr ) {
LOGI ( "failed to load model" );
}
return reinterpret_cast<jlong> ( ctx );
}
JNIEXPORT void JNICALL Java_com_meetrec_core_whisper_LibWhisper_freeContext ( JNIEnv *, jobject, jlong ptr ) {
whisper_free ( as_ctx ( ptr ) );
}
JNIEXPORT jboolean JNICALL Java_com_meetrec_core_whisper_LibWhisper_fullTranscribe ( JNIEnv *env, jobject, jlong ptr,
jint threads, jint beam_size,
jstring language,
jfloatArray audio ) {
whisper_context *ctx = as_ctx ( ptr );
if ( ctx == nullptr ) {
return JNI_FALSE;
}
struct whisper_full_params params = beam_size > 1 ? whisper_full_default_params ( WHISPER_SAMPLING_BEAM_SEARCH )
: whisper_full_default_params ( WHISPER_SAMPLING_GREEDY );
params.n_threads = threads > 0 ? threads : 4;
params.beam_search.beam_size = beam_size > 1 ? beam_size : 0; // 0 = greedy
params.translate = false;
params.print_progress = false;
params.print_special = false;
params.print_realtime = false;
params.print_timestamps = false;
const char *lang = nullptr;
if ( language != nullptr ) {
lang = env->GetStringUTFChars ( language, nullptr );
params.language = lang;
}
jfloat *pcm = env->GetFloatArrayElements ( audio, nullptr );
const jsize n = env->GetArrayLength ( audio );
const int rc = whisper_full ( ctx, params, pcm, n );
env->ReleaseFloatArrayElements ( audio, pcm, JNI_ABORT );
if ( lang != nullptr ) {
env->ReleaseStringUTFChars ( language, lang );
}
if ( rc != 0 ) {
LOGI ( "whisper_full failed with rc=%d", rc );
return JNI_FALSE;
}
return JNI_TRUE;
}
JNIEXPORT jint JNICALL Java_com_meetrec_core_whisper_LibWhisper_getTextSegmentCount ( JNIEnv *, jobject, jlong ptr ) {
return whisper_full_n_segments ( as_ctx ( ptr ) );
}
JNIEXPORT jlong JNICALL Java_com_meetrec_core_whisper_LibWhisper_getTextSegmentStartMs ( JNIEnv *, jobject, jlong ptr,
jint index ) {
// whisper.cpp timestamps are in centiseconds
return whisper_full_get_segment_t0 ( as_ctx ( ptr ), index ) * 10;
}
JNIEXPORT jlong JNICALL Java_com_meetrec_core_whisper_LibWhisper_getTextSegmentEndMs ( JNIEnv *, jobject, jlong ptr,
jint index ) {
return whisper_full_get_segment_t1 ( as_ctx ( ptr ), index ) * 10;
}
JNIEXPORT jstring JNICALL Java_com_meetrec_core_whisper_LibWhisper_getTextSegment ( JNIEnv *env, jobject, jlong ptr,
jint index ) {
return env->NewStringUTF ( whisper_full_get_segment_text ( as_ctx ( ptr ), index ) );
}
} // extern "C"
@@ -0,0 +1,32 @@
package com.meetrec.core.whisper
/**
* Raw JNI bindings into libwhisper_jni.so.
*
* Pointers to native whisper_context are passed as Long tokens; nothing
* here is safe to call from multiple threads concurrently (the native
* context is single-threaded by design) — use [WhisperEngine] instead.
*/
internal object LibWhisper {
init {
System.loadLibrary("whisper_jni")
}
/** Returns 0 on failure. */
external fun initContext(modelPath: String): Long
external fun freeContext(ptr: Long)
/** Runs whisper_full on 16 kHz mono float PCM. Returns false on failure. */
external fun fullTranscribe(
ptr: Long, threads: Int, beamSize: Int, language: String?, audio: FloatArray,
): Boolean
external fun getTextSegmentCount(ptr: Long): Int
external fun getTextSegmentStartMs(ptr: Long, index: Int): Long
external fun getTextSegmentEndMs(ptr: Long, index: Int): Long
external fun getTextSegment(ptr: Long, index: Int): String
}
@@ -0,0 +1,77 @@
package com.meetrec.core.whisper
/**
* On-device Whisper transcription via whisper.cpp (CPU/NEON).
*
* Mirrors the desktop meetrec engine contract: audio in is 16 kHz mono
* float32, segments out carry start/end timestamps and text.
*
* A loaded engine is heavyweight (the whole GGML model lives in native
* memory) and NOT thread-safe: all calls on a given instance must be
* serialized. Call [close] to free the native context when done.
*/
class WhisperEngine private constructor(
private val ptr: Long,
val modelPath: String,
) : AutoCloseable {
data class Segment(val startMs: Long, val endMs: Long, val text: String)
@Volatile
private var closed = false
/**
* Transcribes [audio] (16 kHz mono float32, range roughly [-1, 1]).
*
* Blocking and CPU-heavy — call from a worker dispatcher, not the UI
* thread. [threads] defaults to all big cores; [beamSize] > 1 uses beam
* search (final-pass quality), 1 uses greedy (live-pass speed).
* [language] is an ISO code like "en"/"de", or null to autodetect.
*/
@Synchronized
fun transcribe(
audio: FloatArray,
threads: Int = DEFAULT_THREADS,
beamSize: Int = 5,
language: String? = null,
): List<Segment> {
check(!closed) { "engine is closed" }
check(ptr != 0L) { "engine failed to load" }
check(audio.isNotEmpty()) { "empty audio" }
val ok = LibWhisper.fullTranscribe(ptr, threads, beamSize, language, audio)
check(ok) { "whisper_full failed (corrupt model file?)" }
val n = LibWhisper.getTextSegmentCount(ptr)
return (0 until n).map { i ->
Segment(
startMs = LibWhisper.getTextSegmentStartMs(ptr, i),
endMs = LibWhisper.getTextSegmentEndMs(ptr, i),
text = LibWhisper.getTextSegment(ptr, i).trim(),
)
}
}
@Synchronized
override fun close() {
if (!closed) {
closed = true
if (ptr != 0L) LibWhisper.freeContext(ptr)
}
}
protected fun finalize() = close()
companion object {
/** Big cores; on the Fairphone 6 (SD 7s Gen 3) that is 4. */
val DEFAULT_THREADS: Int =
Runtime.getRuntime().availableProcessors().coerceAtMost(4)
/** Loads a GGML model; throws IllegalStateException on failure. */
fun load(modelPath: String): WhisperEngine {
val ptr = LibWhisper.initContext(modelPath)
check(ptr != 0L) { "failed to load model: $modelPath" }
return WhisperEngine(ptr, modelPath)
}
}
}
+8
View File
@@ -0,0 +1,8 @@
# JVM args for the Gradle daemon (whisper.cpp needs headroom for NDK builds).
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
org.gradle.parallel=true
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
+21
View File
@@ -0,0 +1,21 @@
[versions]
agp = "9.4.0"
kotlin = "2.4.10"
coroutines = "1.11.0"
coreKtx = "1.18.0"
activityCompose = "1.13.0"
composeBom = "2026.06.01"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
Binary file not shown.
+9
View File
@@ -0,0 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+82
View File
@@ -0,0 +1,82 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
+19
View File
@@ -0,0 +1,19 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "meetrec"
include(":app")
include(":core:whisper")
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
#
# fetch-whisper.sh — vendor the whisper.cpp sources needed for the NDK build.
# Pinned to a release tag so the JNI layer never breaks on master churn.
set -euo pipefail
TAG="v1.9.3"
REPO="https://github.com/ggml-org/whisper.cpp"
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/third_party"
mkdir -p "$DIR"
if [ -f "$DIR/whisper.cpp/src/whisper.cpp" ]; then
echo "whisper.cpp already present at $DIR/whisper.cpp"
else
echo "cloning whisper.cpp $TAG into $DIR/whisper.cpp ..."
git clone --depth 1 --branch "$TAG" "$REPO" "$DIR/whisper.cpp"
fi