diff --git a/android/app/src/main/java/com/meetrec/android/RecorderService.kt b/android/app/src/main/java/com/meetrec/android/RecorderService.kt index 7a181d1..168e80c 100644 --- a/android/app/src/main/java/com/meetrec/android/RecorderService.kt +++ b/android/app/src/main/java/com/meetrec/android/RecorderService.kt @@ -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) { // let the live loop drain its current window first liveJob?.cancel() @@ -276,54 +281,43 @@ class RecorderService : Service() { val cfg = session val transcriber: Transcriber? = cfg?.serverUrl?.let { RemoteWhisperEngine(it) } ?: cfg?.finalEngine + + var outputs: List = emptyList() if (transcriber == null) { _state.value = RecordingState.Finished(file, durationMs, sampleRate) - maybeUpload(file, emptyList(), durationMs, cfg) - cleanup() - return - } - - _state.value = RecordingState.Finalizing(file, durationMs) - updateNotification("Transcribing meeting…") - try { - val t0 = SystemClock.elapsedRealtime() - val samples = withContext(Dispatchers.IO) { - file.inputStream().use { WavReader.read(it) } + } else { + _state.value = RecordingState.Finalizing(file, durationMs) + updateNotification("Transcribing meeting…") + try { + val t0 = SystemClock.elapsedRealtime() + val samples = withContext(Dispatchers.IO) { + file.inputStream().use { WavReader.read(it) } + } + 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") + 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. */ - private fun maybeUpload( - file: File, - outputs: List, - durationMs: Long, - cfg: SessionConfig?, - ) { - val url = cfg?.storageUrl?.trim() - if (url.isNullOrBlank()) return - scope.launch { + val storageUrl = cfg?.storageUrl?.trim() + if (!storageUrl.isNullOrBlank()) { + updateNotification("Saving recording…") _uploadState.value = UploadState.Uploading(file) try { val id = withContext(Dispatchers.IO) { StorageClient.upload( - baseUrl = url, + baseUrl = storageUrl, wav = file, txt = outputs.firstOrNull { it.name.endsWith(".txt") }, srt = outputs.firstOrNull { it.name.endsWith(".srt") }, @@ -342,6 +336,7 @@ class RecorderService : Service() { _uploadState.value = UploadState.Error(e.message ?: "upload failed") } } + cleanup() } private fun cleanup() { diff --git a/android/core/whisper/src/main/cpp/whisper_jni.cpp b/android/core/whisper/src/main/cpp/whisper_jni.cpp index 3dd7fbf..c398473 100644 --- a/android/core/whisper/src/main/cpp/whisper_jni.cpp +++ b/android/core/whisper/src/main/cpp/whisper_jni.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include "whisper.h" @@ -17,6 +18,60 @@ namespace { whisper_context *as_ctx ( jlong ptr ) { return reinterpret_cast ( 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 ( s ); + const size_t n = strlen ( s ); + std::vector 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 ( cp ) ); + } else { // surrogate pair for astral planes + cp -= 0x10000; + out.push_back ( static_cast ( 0xD800 + ( cp >> 10 ) ) ); + out.push_back ( static_cast ( 0xDC00 + ( cp & 0x3FF ) ) ); + } + } + if ( out.empty() ) { + return env->NewStringUTF ( "" ); + } + return env->NewString ( out.data(), static_cast ( out.size() ) ); +} + } // namespace 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, 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"