Android: fix upload lifecycle and JNI UTF-8 abort (M3a hardening)
- upload ran as a detached coroutine while cleanup() stopped the
service, and onDestroy's scope.cancel() killed it before any status
appeared; the upload is now part of finishRecording itself and the
service stays foreground ("Saving recording…") until it completes
- JNI getTextSegment used NewStringUTF, which ABORTS THE PROCESS on
invalid UTF-8 — whisper can emit garbled bytes on noisy audio (seen
live: SIGABRT with illegal start byte 0x8d); whisper_jni.cpp now
decodes UTF-8 to UTF-16 itself, replacing bad sequences with U+FFFD
- validated on device end-to-end: record -> local final pass -> automatic
upload -> library entry with all four files (verified server-side)
This commit is contained in:
@@ -266,7 +266,12 @@ class RecorderService : Service() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Final pass + outputs, after the WAV is safely closed. */
|
/**
|
||||||
|
* Final pass + upload + outputs, after the WAV is safely closed.
|
||||||
|
* The service stays foreground ("Saving recording…") until the
|
||||||
|
* upload is done — stopping earlier would cancel the upload
|
||||||
|
* coroutine via onDestroy's scope.cancel().
|
||||||
|
*/
|
||||||
private suspend fun finishRecording(file: File, durationMs: Long, sampleRate: Int) {
|
private suspend fun finishRecording(file: File, durationMs: Long, sampleRate: Int) {
|
||||||
// let the live loop drain its current window first
|
// let the live loop drain its current window first
|
||||||
liveJob?.cancel()
|
liveJob?.cancel()
|
||||||
@@ -276,54 +281,43 @@ class RecorderService : Service() {
|
|||||||
val cfg = session
|
val cfg = session
|
||||||
val transcriber: Transcriber? =
|
val transcriber: Transcriber? =
|
||||||
cfg?.serverUrl?.let { RemoteWhisperEngine(it) } ?: cfg?.finalEngine
|
cfg?.serverUrl?.let { RemoteWhisperEngine(it) } ?: cfg?.finalEngine
|
||||||
|
|
||||||
|
var outputs: List<File> = emptyList()
|
||||||
if (transcriber == null) {
|
if (transcriber == null) {
|
||||||
_state.value = RecordingState.Finished(file, durationMs, sampleRate)
|
_state.value = RecordingState.Finished(file, durationMs, sampleRate)
|
||||||
maybeUpload(file, emptyList(), durationMs, cfg)
|
} else {
|
||||||
cleanup()
|
_state.value = RecordingState.Finalizing(file, durationMs)
|
||||||
return
|
updateNotification("Transcribing meeting…")
|
||||||
}
|
try {
|
||||||
|
val t0 = SystemClock.elapsedRealtime()
|
||||||
_state.value = RecordingState.Finalizing(file, durationMs)
|
val samples = withContext(Dispatchers.IO) {
|
||||||
updateNotification("Transcribing meeting…")
|
file.inputStream().use { WavReader.read(it) }
|
||||||
try {
|
}
|
||||||
val t0 = SystemClock.elapsedRealtime()
|
val segments = transcriber.transcribe(
|
||||||
val samples = withContext(Dispatchers.IO) {
|
samples,
|
||||||
file.inputStream().use { WavReader.read(it) }
|
WhisperEngine.DEFAULT_THREADS,
|
||||||
|
beamSize = 5,
|
||||||
|
language = cfg?.language,
|
||||||
|
)
|
||||||
|
Log.i(TAG, "final: ${segments.size} segments in " +
|
||||||
|
"${SystemClock.elapsedRealtime() - t0} ms")
|
||||||
|
outputs = TranscriptFiles.writeAll(file, durationMs, segments)
|
||||||
|
_state.value = RecordingState.Finished(file, durationMs, sampleRate, segments, outputs)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_state.value = RecordingState.Finished(
|
||||||
|
file, durationMs, sampleRate, error = e.message,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
val segments = transcriber.transcribe(
|
|
||||||
samples,
|
|
||||||
WhisperEngine.DEFAULT_THREADS,
|
|
||||||
beamSize = 5,
|
|
||||||
language = cfg?.language,
|
|
||||||
)
|
|
||||||
Log.i(TAG, "final: ${segments.size} segments in " +
|
|
||||||
"${SystemClock.elapsedRealtime() - t0} ms")
|
|
||||||
val outputs = TranscriptFiles.writeAll(file, durationMs, segments)
|
|
||||||
_state.value = RecordingState.Finished(file, durationMs, sampleRate, segments, outputs)
|
|
||||||
maybeUpload(file, outputs, durationMs, cfg)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
_state.value = RecordingState.Finished(
|
|
||||||
file, durationMs, sampleRate, error = e.message,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
cleanup()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Uploads the bundle to the recording library in the background. */
|
val storageUrl = cfg?.storageUrl?.trim()
|
||||||
private fun maybeUpload(
|
if (!storageUrl.isNullOrBlank()) {
|
||||||
file: File,
|
updateNotification("Saving recording…")
|
||||||
outputs: List<File>,
|
|
||||||
durationMs: Long,
|
|
||||||
cfg: SessionConfig?,
|
|
||||||
) {
|
|
||||||
val url = cfg?.storageUrl?.trim()
|
|
||||||
if (url.isNullOrBlank()) return
|
|
||||||
scope.launch {
|
|
||||||
_uploadState.value = UploadState.Uploading(file)
|
_uploadState.value = UploadState.Uploading(file)
|
||||||
try {
|
try {
|
||||||
val id = withContext(Dispatchers.IO) {
|
val id = withContext(Dispatchers.IO) {
|
||||||
StorageClient.upload(
|
StorageClient.upload(
|
||||||
baseUrl = url,
|
baseUrl = storageUrl,
|
||||||
wav = file,
|
wav = file,
|
||||||
txt = outputs.firstOrNull { it.name.endsWith(".txt") },
|
txt = outputs.firstOrNull { it.name.endsWith(".txt") },
|
||||||
srt = outputs.firstOrNull { it.name.endsWith(".srt") },
|
srt = outputs.firstOrNull { it.name.endsWith(".srt") },
|
||||||
@@ -342,6 +336,7 @@ class RecorderService : Service() {
|
|||||||
_uploadState.value = UploadState.Error(e.message ?: "upload failed")
|
_uploadState.value = UploadState.Error(e.message ?: "upload failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
cleanup()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun cleanup() {
|
private fun cleanup() {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
#include <android/log.h>
|
#include <android/log.h>
|
||||||
#include <jni.h>
|
#include <jni.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
#include "whisper.h"
|
#include "whisper.h"
|
||||||
|
|
||||||
@@ -17,6 +18,60 @@ namespace {
|
|||||||
|
|
||||||
whisper_context *as_ctx ( jlong ptr ) { return reinterpret_cast<whisper_context *> ( ptr ); }
|
whisper_context *as_ctx ( jlong ptr ) { return reinterpret_cast<whisper_context *> ( ptr ); }
|
||||||
|
|
||||||
|
// Decode possibly-invalid UTF-8 to UTF-16 and build a jstring. Whisper can
|
||||||
|
// emit garbled bytes for noise/hallucinations; JNIEnv::NewStringUTF would
|
||||||
|
// ABORT THE PROCESS on such input (Modified UTF-8 check), so we decode
|
||||||
|
// ourselves and replace bad sequences with U+FFFD.
|
||||||
|
jstring to_jstring ( JNIEnv *env, const char *s ) {
|
||||||
|
if ( s == nullptr ) {
|
||||||
|
return env->NewStringUTF ( "" );
|
||||||
|
}
|
||||||
|
const unsigned char *p = reinterpret_cast<const unsigned char *> ( s );
|
||||||
|
const size_t n = strlen ( s );
|
||||||
|
std::vector<jchar> out;
|
||||||
|
out.reserve ( n );
|
||||||
|
size_t i = 0;
|
||||||
|
auto is_cont = [] ( unsigned char c ) { return ( c & 0xC0 ) == 0x80; };
|
||||||
|
while ( i < n ) {
|
||||||
|
unsigned char c = p[ i ];
|
||||||
|
unsigned cp = 0xFFFFFFFF;
|
||||||
|
if ( c < 0x80 ) {
|
||||||
|
cp = c;
|
||||||
|
i += 1;
|
||||||
|
} else if ( ( c & 0xE0 ) == 0xC0 && i + 1 < n && is_cont ( p[ i + 1 ] ) ) {
|
||||||
|
cp = ( ( c & 0x1F ) << 6 ) | ( p[ i + 1 ] & 0x3F );
|
||||||
|
i += 2;
|
||||||
|
} else if ( ( c & 0xF0 ) == 0xE0 && i + 2 < n && is_cont ( p[ i + 1 ] ) && is_cont ( p[ i + 2 ] ) ) {
|
||||||
|
cp = ( ( c & 0x0F ) << 12 ) | ( ( p[ i + 1 ] & 0x3F ) << 6 ) | ( p[ i + 2 ] & 0x3F );
|
||||||
|
i += 3;
|
||||||
|
} else if ( ( c & 0xF8 ) == 0xF0 && i + 3 < n && is_cont ( p[ i + 1 ] ) && is_cont ( p[ i + 2 ] ) &&
|
||||||
|
is_cont ( p[ i + 3 ] ) ) {
|
||||||
|
cp = ( ( c & 0x07 ) << 18 ) | ( ( p[ i + 1 ] & 0x3F ) << 12 ) | ( ( p[ i + 2 ] & 0x3F ) << 6 ) |
|
||||||
|
( p[ i + 3 ] & 0x3F );
|
||||||
|
i += 4;
|
||||||
|
}
|
||||||
|
// reject overlong encodings, surrogates, out-of-range
|
||||||
|
bool ok =
|
||||||
|
cp != 0xFFFFFFFF && !( cp < 0x80 && c >= 0x80 ) && !( cp >= 0xD800 && cp <= 0xDFFF ) && cp <= 0x10FFFF;
|
||||||
|
if ( !ok ) {
|
||||||
|
out.push_back ( 0xFFFD ); // U+FFFD replacement character
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ( cp < 0x10000 ) {
|
||||||
|
out.push_back ( static_cast<jchar> ( cp ) );
|
||||||
|
} else { // surrogate pair for astral planes
|
||||||
|
cp -= 0x10000;
|
||||||
|
out.push_back ( static_cast<jchar> ( 0xD800 + ( cp >> 10 ) ) );
|
||||||
|
out.push_back ( static_cast<jchar> ( 0xDC00 + ( cp & 0x3FF ) ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( out.empty() ) {
|
||||||
|
return env->NewStringUTF ( "" );
|
||||||
|
}
|
||||||
|
return env->NewString ( out.data(), static_cast<jsize> ( out.size() ) );
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
extern "C" {
|
extern "C" {
|
||||||
@@ -97,7 +152,7 @@ JNIEXPORT jlong JNICALL Java_com_meetrec_core_whisper_LibWhisper_getTextSegmentE
|
|||||||
|
|
||||||
JNIEXPORT jstring JNICALL Java_com_meetrec_core_whisper_LibWhisper_getTextSegment ( JNIEnv *env, jobject, jlong ptr,
|
JNIEXPORT jstring JNICALL Java_com_meetrec_core_whisper_LibWhisper_getTextSegment ( JNIEnv *env, jobject, jlong ptr,
|
||||||
jint index ) {
|
jint index ) {
|
||||||
return env->NewStringUTF ( whisper_full_get_segment_text ( as_ctx ( ptr ), index ) );
|
return to_jstring ( env, whisper_full_get_segment_text ( as_ctx ( ptr ), index ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
} // extern "C"
|
} // extern "C"
|
||||||
|
|||||||
Reference in New Issue
Block a user