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
+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)
}
}
}