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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user