From 02b28c02e67eced8ba76b1b5cf4ab4ee9e47c45b Mon Sep 17 00:00:00 2001 From: Patrick Zauner <74716024+pzauner@users.noreply.github.com> Date: Sun, 4 Jan 2026 02:22:44 +0100 Subject: [PATCH 01/14] feat: Add Whisper offline speech recognition - Core implementation This commit adds the foundational components for Whisper-based offline speech recognition: Core Components: - WhisperModel: Enum defining available models (Tiny, Base, Small) - WhisperRecordBuffer: Audio buffer management for PCM samples - WhisperRecorder: Audio recording with Voice Activity Detection (VAD) - WhisperEngine: TensorFlow Lite inference engine for Whisper - WhisperUtil: Mel spectrogram calculation and token decoding - WhisperRecognitionManager: High-level API similar to SpeechRecognitionManager Dependencies: - TensorFlow Lite 2.15.0 (inference engine) - TensorFlow Lite Support 0.4.4 (tensor utilities) - Android VAD WebRTC 2.0.9 (voice activity detection) Settings Integration: - Added WhisperSettings to SettingsManager - getUseWhisper/setUseWhisper for toggle - getWhisperModel/setWhisperModel for model selection Localization: - Complete EN and DE translations for Whisper UI strings Technical Details: - Uses TFLite models from Hugging Face (DocWolle/whisper_tflite_models) - Supports multilingual (99 languages) and English-only models - Automatic silence detection with VAD for seamless UX - 16kHz audio sampling as required by Whisper - Max 30s recording duration Next Steps: - Model download UI - Integration into PhysicalKeyboardInputMethodService - Settings screen for model management Related to #whisper-integration --- app/build.gradle.kts | 5 + .../palsoftware/pastiera/SettingsManager.kt | 47 +++ .../inputmethod/whisper/WhisperEngine.kt | 269 ++++++++++++++ .../inputmethod/whisper/WhisperModel.kt | 64 ++++ .../whisper/WhisperRecognitionManager.kt | 337 ++++++++++++++++++ .../whisper/WhisperRecordBuffer.kt | 57 +++ .../inputmethod/whisper/WhisperRecorder.kt | 244 +++++++++++++ .../inputmethod/whisper/WhisperUtil.kt | 221 ++++++++++++ app/src/main/res/values-de/strings.xml | 20 ++ app/src/main/res/values/strings.xml | 20 ++ 10 files changed, 1284 insertions(+) create mode 100644 app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperEngine.kt create mode 100644 app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperModel.kt create mode 100644 app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperRecognitionManager.kt create mode 100644 app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperRecordBuffer.kt create mode 100644 app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperRecorder.kt create mode 100644 app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperUtil.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dfcf3b36..5401e30c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -209,6 +209,11 @@ dependencies { // Shizuku for ADB shell access implementation("dev.rikka.shizuku:api:13.1.5") implementation("dev.rikka.shizuku:provider:13.1.5") + // TensorFlow Lite for Whisper Speech Recognition + implementation("org.tensorflow:tensorflow-lite:2.15.0") + implementation("org.tensorflow:tensorflow-lite-support:0.4.4") + // Voice Activity Detection (VAD) for Whisper + implementation("com.github.gkonovalov.android-vad:webrtc:2.0.9") testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/app/src/main/java/it/palsoftware/pastiera/SettingsManager.kt b/app/src/main/java/it/palsoftware/pastiera/SettingsManager.kt index 83338236..95153c83 100644 --- a/app/src/main/java/it/palsoftware/pastiera/SettingsManager.kt +++ b/app/src/main/java/it/palsoftware/pastiera/SettingsManager.kt @@ -849,6 +849,53 @@ object SettingsManager { .putString(KEY_LONG_PRESS_MODIFIER, validModifier) .apply() } + + // ================================================================= + // Whisper Speech Recognition Settings + // ================================================================= + + private const val KEY_USE_WHISPER = "use_whisper_speech_recognition" + private const val KEY_WHISPER_MODEL = "whisper_model" + private const val DEFAULT_USE_WHISPER = false + private const val DEFAULT_WHISPER_MODEL = "BASE" // WhisperModel enum name + + /** + * Gets whether Whisper should be used instead of Google Speech Recognition. + */ + fun getUseWhisper(context: Context): Boolean { + return getPreferences(context).getBoolean(KEY_USE_WHISPER, DEFAULT_USE_WHISPER) + } + + /** + * Sets whether to use Whisper for speech recognition. + */ + fun setUseWhisper(context: Context, useWhisper: Boolean) { + getPreferences(context).edit() + .putBoolean(KEY_USE_WHISPER, useWhisper) + .apply() + } + + /** + * Gets the selected Whisper model. + */ + fun getWhisperModel(context: Context): it.palsoftware.pastiera.inputmethod.whisper.WhisperModel { + val modelName = getPreferences(context).getString(KEY_WHISPER_MODEL, DEFAULT_WHISPER_MODEL) ?: DEFAULT_WHISPER_MODEL + return try { + it.palsoftware.pastiera.inputmethod.whisper.WhisperModel.valueOf(modelName) + } catch (e: IllegalArgumentException) { + Log.w(TAG, "Invalid Whisper model name: $modelName, using default") + it.palsoftware.pastiera.inputmethod.whisper.WhisperModel.BASE + } + } + + /** + * Sets the Whisper model to use. + */ + fun setWhisperModel(context: Context, model: it.palsoftware.pastiera.inputmethod.whisper.WhisperModel) { + getPreferences(context).edit() + .putString(KEY_WHISPER_MODEL, model.name) + .apply() + } /** * Returns true if long press uses Shift, false if it uses Alt. diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperEngine.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperEngine.kt new file mode 100644 index 00000000..d4112779 --- /dev/null +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperEngine.kt @@ -0,0 +1,269 @@ +package it.palsoftware.pastiera.inputmethod.whisper + +import android.content.Context +import android.util.Log +import org.tensorflow.lite.DataType +import org.tensorflow.lite.Interpreter +import org.tensorflow.lite.support.tensorbuffer.TensorBuffer +import java.io.File +import java.io.FileInputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.IntBuffer + +/** + * Whisper inference engine using TensorFlow Lite. + * Handles model loading, audio processing, and transcription. + */ +class WhisperEngine(private val context: Context) { + companion object { + private const val TAG = "WhisperEngine" + private const val WHISPER_SAMPLE_RATE = 16000 + private const val WHISPER_CHUNK_SIZE = 30 // seconds + private const val MEL_BINS = 80 + private const val N_FFT = 400 + private const val HOP_LENGTH = 160 + + // Special tokens + private const val TOKEN_EOT = 50256 // End of text + private const val TOKEN_TRANSCRIBE = 50358 + private const val TOKEN_TRANSLATE = 50357 + private const val TOKEN_NOTIMESTAMPS = 50363 + } + + private var interpreter: Interpreter? = null + private var isInitialized = false + private var isMultilingual = false + private val whisperUtil = WhisperUtil(context) + + /** + * Loads and initializes the Whisper model. + */ + fun initialize(modelFile: File, vocabFile: File, isMultilingual: Boolean) { + try { + // Load model + val modelBuffer = loadModelFile(modelFile) + + // Configure TensorFlow Lite interpreter + val options = Interpreter.Options().apply { + useXNNPACK = false // Cannot use XNNPACK with dynamic tensors + numThreads = Runtime.getRuntime().availableProcessors() + setCancellable(true) + } + + interpreter = Interpreter(modelBuffer, options) + this.isMultilingual = isMultilingual + + // Load vocabulary and filters + whisperUtil.loadFiltersAndVocab(isMultilingual, vocabFile) + + isInitialized = true + Log.d(TAG, "Whisper engine initialized: ${modelFile.name}") + } catch (e: Exception) { + Log.e(TAG, "Error initializing Whisper engine", e) + isInitialized = false + throw e + } + } + + /** + * Loads model file into ByteBuffer. + */ + private fun loadModelFile(modelFile: File): ByteBuffer { + FileInputStream(modelFile).use { inputStream -> + val fileChannel = inputStream.channel + val startOffset = 0L + val declaredLength = fileChannel.size() + return fileChannel.map( + java.nio.channels.FileChannel.MapMode.READ_ONLY, + startOffset, + declaredLength + ) + } + } + + /** + * Processes audio from WhisperRecordBuffer and returns transcription. + */ + fun processRecordBuffer(languageToken: Int = -1): WhisperResult? { + if (!isInitialized) { + Log.e(TAG, "Engine not initialized") + return null + } + + val samples = WhisperRecordBuffer.getSamples() + if (samples == null || samples.isEmpty()) { + Log.e(TAG, "No audio samples available") + return null + } + + try { + val startTime = System.currentTimeMillis() + + // Calculate Mel spectrogram + Log.d(TAG, "Calculating Mel spectrogram...") + val melSpectrogram = getMelSpectrogram(samples) + + // Run inference + Log.d(TAG, "Running inference...") + val result = runInference(melSpectrogram, languageToken) + + val timeTaken = System.currentTimeMillis() - startTime + Log.d(TAG, "Transcription completed in ${timeTaken}ms") + + return result + } catch (e: Exception) { + Log.e(TAG, "Error processing audio", e) + return null + } + } + + /** + * Calculates Mel spectrogram from audio samples. + */ + private fun getMelSpectrogram(samples: FloatArray): FloatArray { + val fixedInputSize = WHISPER_SAMPLE_RATE * WHISPER_CHUNK_SIZE + val inputSamples = FloatArray(fixedInputSize) + + // Copy available samples, pad with zeros if needed + val copyLength = minOf(samples.size, fixedInputSize) + System.arraycopy(samples, 0, inputSamples, 0, copyLength) + + val cores = Runtime.getRuntime().availableProcessors() + return whisperUtil.getMelSpectrogram(inputSamples, copyLength, cores) + } + + /** + * Runs TensorFlow Lite inference on Mel spectrogram. + */ + private fun runInference(melSpectrogram: FloatArray, languageToken: Int): WhisperResult { + val interpreter = this.interpreter ?: throw IllegalStateException("Interpreter not initialized") + + // Get input and output tensors + val inputTensor = interpreter.getInputTensor(0) + val outputTensor = interpreter.getOutputTensor(0) + + // Prepare input buffer + val inputSize = inputTensor.shape()[0] * inputTensor.shape()[1] * inputTensor.shape()[2] * Float.SIZE_BYTES / 8 + val inputBuffer = ByteBuffer.allocateDirect(inputSize).apply { + order(ByteOrder.nativeOrder()) + melSpectrogram.forEach { putFloat(it) } + rewind() + } + + // Prepare output buffer + val outputBuffer = TensorBuffer.createFixedSize(outputTensor.shape(), DataType.FLOAT32) + + // Select appropriate signature based on language token + val signatureKey = when { + languageToken != -1 && "serving_transcribe_lang" in interpreter.signatureKeys -> "serving_transcribe_lang" + "serving_transcribe" in interpreter.signatureKeys -> "serving_transcribe" + else -> "serving_default" + } + + Log.d(TAG, "Using signature: $signatureKey") + + // Prepare inputs + val inputsMap = mutableMapOf() + val inputs = interpreter.getSignatureInputs(signatureKey) + inputsMap[inputs[0]] = inputBuffer + + // Add language token if using serving_transcribe_lang + if (signatureKey == "serving_transcribe_lang" && inputs.size > 1) { + val langTokenBuffer = IntBuffer.allocate(1).apply { + put(languageToken) + rewind() + } + inputsMap[inputs[1]] = langTokenBuffer + Log.d(TAG, "Using language token: $languageToken") + } + + // Prepare outputs + val outputsMap = mutableMapOf() + val outputs = interpreter.getSignatureOutputs(signatureKey) + outputsMap[outputs[0]] = outputBuffer.buffer + + // Run inference + try { + interpreter.runSignature(inputsMap, outputsMap, signatureKey) + } catch (e: Exception) { + Log.e(TAG, "Inference failed", e) + return WhisperResult("", "", 0f) + } + + // Decode output tokens to text + return decodeTokens(outputBuffer) + } + + /** + * Decodes output tokens from the model into text. + */ + private fun decodeTokens(outputBuffer: TensorBuffer): WhisperResult { + val tokens = mutableListOf() + var detectedLanguage = "" + val outputLen = outputBuffer.intArray.size + + Log.d(TAG, "Decoding ${outputLen} tokens") + + outputBuffer.buffer.rewind() + + for (i in 0 until outputLen) { + val token = outputBuffer.buffer.int + + if (token == TOKEN_EOT) { + Log.d(TAG, "End of text token reached at position $i") + break + } + + // Handle special tokens + when { + token == TOKEN_TRANSCRIBE -> { + Log.d(TAG, "Transcription task detected") + } + token == TOKEN_TRANSLATE -> { + Log.d(TAG, "Translation task detected") + } + token in 50259..50357 -> { + // Language token + detectedLanguage = whisperUtil.getLanguageFromToken(token) + Log.d(TAG, "Detected language: $detectedLanguage (token $token)") + } + token < TOKEN_EOT -> { + // Regular text token + tokens.add(token) + } + else -> { + Log.d(TAG, "Skipping special token: $token") + } + } + } + + // Convert tokens to text + val textBytes = mutableListOf() + for (token in tokens) { + val wordBytes = whisperUtil.getWordFromToken(token) + textBytes.addAll(wordBytes.toList()) + } + + val text = String(textBytes.toByteArray(), Charsets.UTF_8).trim() + Log.d(TAG, "Decoded text: '$text'") + + return WhisperResult(text, detectedLanguage, 1.0f) + } + + /** + * Releases all resources. + */ + fun deinitialize() { + interpreter?.apply { + setCancelled(true) + close() + } + interpreter = null + isInitialized = false + Log.d(TAG, "Whisper engine deinitialized") + } + + fun isInitialized(): Boolean = isInitialized +} + diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperModel.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperModel.kt new file mode 100644 index 00000000..b4ae4a67 --- /dev/null +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperModel.kt @@ -0,0 +1,64 @@ +package it.palsoftware.pastiera.inputmethod.whisper + +/** + * Represents available Whisper models for speech recognition. + */ +enum class WhisperModel( + val displayName: String, + val fileName: String, + val sizeBytes: Long, + val isMultilingual: Boolean, + val description: String +) { + TINY_EN( + displayName = "Tiny (English only)", + fileName = "whisper_tiny_en.tflite", + sizeBytes = 75 * 1024 * 1024, // ~75 MB + isMultilingual = false, + description = "Fast, English only, good for clear speech" + ), + BASE( + displayName = "Base (Multilingual)", + fileName = "whisper_base.tflite", + sizeBytes = 150 * 1024 * 1024, // ~150 MB + isMultilingual = true, + description = "Balanced quality and speed, supports 99 languages" + ), + SMALL( + displayName = "Small (Multilingual)", + fileName = "whisper_small.tflite", + sizeBytes = 500 * 1024 * 1024, // ~500 MB + isMultilingual = true, + description = "Excellent quality, may be slower on some devices" + ); + + companion object { + fun fromFileName(fileName: String): WhisperModel? { + return values().firstOrNull { it.fileName == fileName } + } + } +} + +/** + * Download URLs for Whisper models from Hugging Face. + */ +object WhisperModelUrls { + private const val BASE_URL = "https://huggingface.co/DocWolle/whisper_tflite_models/resolve/main" + + fun getDownloadUrl(model: WhisperModel): String { + return "$BASE_URL/${model.fileName}" + } + + const val VOCAB_MULTILINGUAL_URL = "$BASE_URL/filters_vocab_multilingual.bin" + const val VOCAB_EN_URL = "$BASE_URL/filters_vocab_en.bin" +} + +/** + * Result from Whisper speech recognition. + */ +data class WhisperResult( + val text: String, + val language: String, + val confidence: Float = 1.0f +) + diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperRecognitionManager.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperRecognitionManager.kt new file mode 100644 index 00000000..216c23f8 --- /dev/null +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperRecognitionManager.kt @@ -0,0 +1,337 @@ +package it.palsoftware.pastiera.inputmethod.whisper + +import android.Manifest +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.view.inputmethod.InputConnection +import android.widget.Toast +import androidx.core.content.ContextCompat +import it.palsoftware.pastiera.R +import it.palsoftware.pastiera.SettingsManager +import it.palsoftware.pastiera.inputmethod.AutoCapitalizeHelper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.util.Locale + +/** + * Manages Whisper-based speech recognition. + * Provides an API similar to SpeechRecognitionManager for seamless integration. + */ +class WhisperRecognitionManager( + private val context: Context, + private val inputConnectionProvider: () -> InputConnection?, + private val onError: ((String) -> Unit)? = null, + private val onRecognitionStateChanged: ((Boolean) -> Unit)? = null, + private val shouldDisableAutoCapitalize: () -> Boolean = { false }, + private val onAudioLevelChanged: ((Float) -> Unit)? = null +) { + companion object { + private const val TAG = "WhisperRecognitionMgr" + private const val MODELS_DIR = "whisper_models" + } + + private var whisperEngine: WhisperEngine? = null + private var whisperRecorder: WhisperRecorder? = null + private var isRecognizing = false + private var isProcessing = false + + /** + * Checks if Whisper is available (model is downloaded). + */ + fun isAvailable(): Boolean { + val selectedModel = SettingsManager.getWhisperModel(context) + val modelFile = getModelFile(selectedModel) + val vocabFile = getVocabFile(selectedModel) + return modelFile.exists() && vocabFile.exists() + } + + /** + * Gets the model file for the selected Whisper model. + */ + private fun getModelFile(model: WhisperModel): File { + val modelsDir = File(context.getExternalFilesDir(null), MODELS_DIR) + return File(modelsDir, model.fileName) + } + + /** + * Gets the vocab file for the selected Whisper model. + */ + private fun getVocabFile(model: WhisperModel): File { + val modelsDir = File(context.getExternalFilesDir(null), MODELS_DIR) + val vocabFileName = if (model.isMultilingual) { + "filters_vocab_multilingual.bin" + } else { + "filters_vocab_en.bin" + } + return File(modelsDir, vocabFileName) + } + + /** + * Formats text according to standard auto-capitalization rules. + */ + private fun formatTextWithAutoCapitalization(text: String): String { + if (text.isEmpty()) return text + + val inputConnection = inputConnectionProvider() ?: return text + + if (shouldDisableAutoCapitalize()) { + return text + } + + var formatted = text + + // Capitalize first letter if needed + val shouldCapitalizeFirst = AutoCapitalizeHelper.shouldAutoCapitalizeAtCursor( + context = context, + inputConnection = inputConnection, + shouldDisableAutoCapitalize = shouldDisableAutoCapitalize() + ) && SettingsManager.getAutoCapitalizeFirstLetter(context) + + if (shouldCapitalizeFirst && formatted.isNotEmpty()) { + formatted = formatted.replaceFirstChar { + if (it.isLowerCase()) it.titlecase(Locale.getDefault()) + else it.toString() + } + } + + // Capitalize after sentence-ending punctuation + if (SettingsManager.getAutoCapitalizeAfterPeriod(context)) { + formatted = formatted.replace(Regex("([.!?]\\s+)([a-z])")) { matchResult -> + matchResult.groupValues[1] + matchResult.groupValues[2].uppercase() + } + } + + return formatted + } + + /** + * Inserts recognized text into the input connection. + */ + private fun insertRecognizedText(text: String) { + Handler(Looper.getMainLooper()).post { + val inputConnection = inputConnectionProvider() ?: return@post + + try { + var textToCommit = formatTextWithAutoCapitalization(text) + + // Add spacing rules + val textBeforeCursor = inputConnection.getTextBeforeCursor(10, 0) + if (textBeforeCursor != null && textBeforeCursor.isNotEmpty()) { + val lastChar = textBeforeCursor.last() + if (lastChar.isLetter()) { + textToCommit = " $textToCommit" + } + } + + // Always add space at the end + textToCommit += " " + + inputConnection.commitText(textToCommit, 1) + Log.d(TAG, "Inserted recognized text: '$textToCommit'") + } catch (e: Exception) { + Log.e(TAG, "Error inserting text", e) + } + } + } + + /** + * Initializes the Whisper engine with the selected model. + */ + private fun ensureWhisperEngine(): Boolean { + if (whisperEngine?.isInitialized() == true) { + return true + } + + try { + val selectedModel = SettingsManager.getWhisperModel(context) + val modelFile = getModelFile(selectedModel) + val vocabFile = getVocabFile(selectedModel) + + if (!modelFile.exists()) { + Log.e(TAG, "Model file not found: ${modelFile.absolutePath}") + onError?.invoke(context.getString(R.string.whisper_error_model_not_found)) + return false + } + + if (!vocabFile.exists()) { + Log.e(TAG, "Vocab file not found: ${vocabFile.absolutePath}") + onError?.invoke(context.getString(R.string.whisper_error_model_not_found)) + return false + } + + whisperEngine = WhisperEngine(context) + whisperEngine?.initialize(modelFile, vocabFile, selectedModel.isMultilingual) + + Log.d(TAG, "Whisper engine initialized with model: ${selectedModel.displayName}") + return true + } catch (e: Exception) { + Log.e(TAG, "Error initializing Whisper engine", e) + onError?.invoke(context.getString(R.string.whisper_error_initialization)) + return false + } + } + + /** + * Starts Whisper speech recognition. + */ + fun startRecognition() { + // Check RECORD_AUDIO permission + if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) + != android.content.pm.PackageManager.PERMISSION_GRANTED) { + Log.e(TAG, "RECORD_AUDIO permission not granted") + onError?.invoke(context.getString(R.string.speech_recognition_error_permission)) + return + } + + if (isRecognizing) { + Log.w(TAG, "Already recognizing") + return + } + + // Ensure engine is initialized + if (!ensureWhisperEngine()) { + return + } + + try { + isRecognizing = true + onRecognitionStateChanged?.invoke(true) + + // Initialize recorder + whisperRecorder = WhisperRecorder(context).apply { + setListener(object : WhisperRecorder.RecorderListener { + override fun onRecordingStarted() { + Log.d(TAG, "Recording started") + } + + override fun onRecording() { + // Update audio level feedback (simplified) + onAudioLevelChanged?.invoke(10f) + } + + override fun onRecordingDone() { + Log.d(TAG, "Recording done, starting transcription") + onAudioLevelChanged?.invoke(-20f) + startTranscription() + } + + override fun onRecordingError(error: String) { + Log.e(TAG, "Recording error: $error") + isRecognizing = false + onRecognitionStateChanged?.invoke(false) + onError?.invoke(error) + } + }) + + initVad() + start() + } + + Log.d(TAG, "Whisper recognition started") + } catch (e: Exception) { + Log.e(TAG, "Error starting Whisper recognition", e) + isRecognizing = false + onRecognitionStateChanged?.invoke(false) + onError?.invoke(context.getString(R.string.whisper_error_generic)) + } + } + + /** + * Starts transcription after recording is complete. + */ + private fun startTranscription() { + if (isProcessing) { + Log.w(TAG, "Already processing") + return + } + + isProcessing = true + + // Show "processing" toast + Handler(Looper.getMainLooper()).post { + Toast.makeText(context, R.string.whisper_processing, Toast.LENGTH_SHORT).show() + } + + // Run transcription in background + CoroutineScope(Dispatchers.IO).launch { + try { + // Get language token for better recognition + val languageToken = getLanguageToken() + + // Process audio + val result = whisperEngine?.processRecordBuffer(languageToken) + + withContext(Dispatchers.Main) { + isProcessing = false + isRecognizing = false + onRecognitionStateChanged?.invoke(false) + + if (result != null && result.text.isNotEmpty()) { + Log.d(TAG, "Transcription result: '${result.text}' (language: ${result.language})") + insertRecognizedText(result.text) + } else { + Log.w(TAG, "No transcription result") + onError?.invoke(context.getString(R.string.speech_recognition_error_no_match)) + } + } + } catch (e: Exception) { + Log.e(TAG, "Error during transcription", e) + withContext(Dispatchers.Main) { + isProcessing = false + isRecognizing = false + onRecognitionStateChanged?.invoke(false) + onError?.invoke(context.getString(R.string.whisper_error_generic)) + } + } + } + } + + /** + * Gets language token based on device locale. + */ + private fun getLanguageToken(): Int { + val locale = context.resources.configuration.locales[0] + val languageCode = locale?.language ?: "auto" + + // Map language codes to Whisper tokens (50259+) + return when (languageCode) { + "en" -> 50259 + "de" -> 50261 + "es" -> 50262 + "fr" -> 50265 + "it" -> 50274 + "pl" -> 50269 + else -> -1 // Auto-detect + } + } + + /** + * Stops recognition if active. + */ + fun stopRecognition() { + whisperRecorder?.stop() + isRecognizing = false + onRecognitionStateChanged?.invoke(false) + Log.d(TAG, "Whisper recognition stopped") + } + + /** + * Releases all resources. + */ + fun destroy() { + whisperRecorder?.release() + whisperRecorder = null + whisperEngine?.deinitialize() + whisperEngine = null + WhisperRecordBuffer.clear() + isRecognizing = false + isProcessing = false + Log.d(TAG, "Whisper recognition manager destroyed") + } +} + diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperRecordBuffer.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperRecordBuffer.kt new file mode 100644 index 00000000..c5928a86 --- /dev/null +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperRecordBuffer.kt @@ -0,0 +1,57 @@ +package it.palsoftware.pastiera.inputmethod.whisper + +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * Buffer for audio recording data. + * Stores PCM audio samples and provides conversion utilities. + */ +object WhisperRecordBuffer { + private var outputBuffer: FloatArray? = null + + /** + * Sets the output buffer with PCM_FLOAT samples. + */ + fun setOutputBuffer(buffer: FloatArray) { + outputBuffer = buffer + } + + /** + * Gets the output buffer with PCM_FLOAT samples. + */ + fun getOutputBuffer(): FloatArray? { + return outputBuffer + } + + /** + * Gets samples as FloatArray for processing. + */ + fun getSamples(): FloatArray? { + return outputBuffer + } + + /** + * Clears the buffer. + */ + fun clear() { + outputBuffer = null + } + + /** + * Converts PCM16 ByteBuffer to FloatArray (normalized to [-1.0, 1.0]). + */ + fun convertPCM16ToFloat(pcm16Buffer: ByteBuffer): FloatArray { + pcm16Buffer.order(ByteOrder.LITTLE_ENDIAN) + val shortBuffer = pcm16Buffer.asShortBuffer() + val floatBuffer = FloatArray(shortBuffer.remaining()) + + for (i in floatBuffer.indices) { + // Normalize PCM16 samples to [-1.0, 1.0] + floatBuffer[i] = shortBuffer.get(i) / 32768.0f + } + + return floatBuffer + } +} + diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperRecorder.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperRecorder.kt new file mode 100644 index 00000000..595cf9ff --- /dev/null +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperRecorder.kt @@ -0,0 +1,244 @@ +package it.palsoftware.pastiera.inputmethod.whisper + +import android.content.Context +import android.media.AudioFormat +import android.media.AudioRecord +import android.media.MediaRecorder +import android.util.Log +import com.konovalov.vad.webrtc.Vad +import com.konovalov.vad.webrtc.VadListener +import com.konovalov.vad.webrtc.config.FrameSize +import com.konovalov.vad.webrtc.config.Mode +import com.konovalov.vad.webrtc.config.SampleRate +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * Manages audio recording for Whisper speech recognition. + * Includes Voice Activity Detection (VAD) to automatically stop recording when speech ends. + */ +class WhisperRecorder(private val context: Context) { + companion object { + private const val TAG = "WhisperRecorder" + private const val SAMPLE_RATE = 16000 // Whisper requires 16kHz + private const val CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO + private const val AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT + private const val MAX_RECORDING_DURATION_MS = 30000L // 30 seconds max + private const val SILENCE_DURATION_MS = 1500L // Stop after 1.5s of silence + } + + interface RecorderListener { + fun onRecordingStarted() + fun onRecording() + fun onRecordingDone() + fun onRecordingError(error: String) + } + + private var audioRecord: AudioRecord? = null + private var recordingJob: Job? = null + private var vad: Vad? = null + private var listener: RecorderListener? = null + private var isRecording = false + private val audioBuffer = mutableListOf() + private var lastSpeechTime = 0L + private var recordingStartTime = 0L + + fun setListener(listener: RecorderListener) { + this.listener = listener + } + + /** + * Initializes Voice Activity Detection (VAD). + */ + fun initVad() { + try { + vad = Vad.builder() + .setSampleRate(SampleRate.SAMPLE_RATE_16K) + .setFrameSize(FrameSize.FRAME_SIZE_480) // 30ms frames + .setMode(Mode.NORMAL) // Balance between accuracy and CPU + .setSilenceDurationMillis(500) // Detect silence after 500ms + .setSpeechDurationMillis(100) // Minimum speech duration + .setContext(context) + .build() + + vad?.addListener(object : VadListener { + override fun onSpeechDetected() { + Log.d(TAG, "VAD: Speech detected") + lastSpeechTime = System.currentTimeMillis() + } + + override fun onNoiseDetected() { + Log.d(TAG, "VAD: Noise detected (no speech)") + } + }) + + vad?.start() + Log.d(TAG, "VAD initialized and started") + } catch (e: Exception) { + Log.e(TAG, "Error initializing VAD", e) + } + } + + /** + * Starts recording audio. + */ + fun start() { + if (isRecording) { + Log.w(TAG, "Already recording") + return + } + + try { + val bufferSize = AudioRecord.getMinBufferSize( + SAMPLE_RATE, + CHANNEL_CONFIG, + AUDIO_FORMAT + ) + + if (bufferSize == AudioRecord.ERROR || bufferSize == AudioRecord.ERROR_BAD_VALUE) { + listener?.onRecordingError("Invalid buffer size") + return + } + + audioRecord = AudioRecord( + MediaRecorder.AudioSource.VOICE_RECOGNITION, + SAMPLE_RATE, + CHANNEL_CONFIG, + AUDIO_FORMAT, + bufferSize * 2 // Double buffer for safety + ) + + if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) { + listener?.onRecordingError("AudioRecord not initialized") + return + } + + audioBuffer.clear() + audioRecord?.startRecording() + isRecording = true + recordingStartTime = System.currentTimeMillis() + lastSpeechTime = recordingStartTime + listener?.onRecordingStarted() + + // Start recording loop in coroutine + recordingJob = CoroutineScope(Dispatchers.IO).launch { + recordAudio() + } + + Log.d(TAG, "Recording started") + } catch (e: SecurityException) { + Log.e(TAG, "Permission denied for audio recording", e) + listener?.onRecordingError("Permission denied") + } catch (e: Exception) { + Log.e(TAG, "Error starting recording", e) + listener?.onRecordingError(e.message ?: "Unknown error") + } + } + + /** + * Recording loop that reads audio data and processes it with VAD. + */ + private suspend fun recordAudio() { + val bufferSize = AudioRecord.getMinBufferSize( + SAMPLE_RATE, + CHANNEL_CONFIG, + AUDIO_FORMAT + ) + val buffer = ShortArray(bufferSize / 2) // PCM16 = 2 bytes per sample + + while (isRecording && isActive) { + val readCount = audioRecord?.read(buffer, 0, buffer.size) ?: 0 + + if (readCount > 0) { + // Add to buffer + audioBuffer.addAll(buffer.take(readCount)) + + // Process with VAD + vad?.setContinuousSpeechListener(buffer, readCount) { speechActive -> + if (speechActive) { + lastSpeechTime = System.currentTimeMillis() + } + } + + listener?.onRecording() + + // Check if we should stop recording + val currentTime = System.currentTimeMillis() + val silenceDuration = currentTime - lastSpeechTime + val totalDuration = currentTime - recordingStartTime + + if (totalDuration >= MAX_RECORDING_DURATION_MS) { + Log.d(TAG, "Max recording duration reached") + stop() + break + } + + if (silenceDuration >= SILENCE_DURATION_MS && audioBuffer.isNotEmpty()) { + Log.d(TAG, "Silence detected, stopping recording") + stop() + break + } + } else if (readCount < 0) { + Log.e(TAG, "Error reading audio: $readCount") + break + } + + // Small delay to prevent busy loop + delay(10) + } + } + + /** + * Stops recording and processes the captured audio. + */ + fun stop() { + if (!isRecording) { + return + } + + isRecording = false + recordingJob?.cancel() + recordingJob = null + + try { + audioRecord?.stop() + audioRecord?.release() + audioRecord = null + + vad?.stop() + vad?.close() + vad = null + + // Convert buffer to FloatArray and store in WhisperRecordBuffer + val floatSamples = audioBuffer.map { it / 32768.0f }.toFloatArray() + WhisperRecordBuffer.setOutputBuffer(floatSamples) + + listener?.onRecordingDone() + Log.d(TAG, "Recording stopped. Captured ${audioBuffer.size} samples (${audioBuffer.size / SAMPLE_RATE}s)") + } catch (e: Exception) { + Log.e(TAG, "Error stopping recording", e) + listener?.onRecordingError(e.message ?: "Unknown error") + } + } + + /** + * Checks if recording is in progress. + */ + fun isInProgress(): Boolean = isRecording + + /** + * Releases all resources. + */ + fun release() { + stop() + audioBuffer.clear() + WhisperRecordBuffer.clear() + } +} + diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperUtil.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperUtil.kt new file mode 100644 index 00000000..47e93e69 --- /dev/null +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperUtil.kt @@ -0,0 +1,221 @@ +package it.palsoftware.pastiera.inputmethod.whisper + +import android.content.Context +import android.util.Log +import java.io.File +import java.nio.charset.StandardCharsets +import kotlin.math.* + +/** + * Utility class for Whisper model operations. + * Handles Mel spectrogram calculation, vocabulary loading, and token decoding. + */ +class WhisperUtil(private val context: Context) { + companion object { + private const val TAG = "WhisperUtil" + const val WHISPER_SAMPLE_RATE = 16000 + const val WHISPER_CHUNK_SIZE = 30 // seconds + private const val N_FFT = 400 + private const val HOP_LENGTH = 160 + private const val N_MEL = 80 + private const val MEL_LOW_HZ = 0f + private const val MEL_HIGH_HZ = 8000f + } + + private var vocab: Array? = null + private var tokenToWord: Map? = null + + /** + * Loads vocabulary and filters from file. + */ + fun loadFiltersAndVocab(isMultilingual: Boolean, vocabFile: File): Boolean { + return try { + // Load vocabulary from binary file + val vocabBytes = vocabFile.readBytes() + + // Parse vocabulary (simplified - assumes newline-separated tokens) + val vocabString = String(vocabBytes, StandardCharsets.UTF_8) + vocab = vocabString.split("\n").toTypedArray() + + // Build token-to-word mapping + val mapping = mutableMapOf() + vocab?.forEachIndexed { index, word -> + mapping[index] = word.toByteArray(StandardCharsets.UTF_8) + } + tokenToWord = mapping + + Log.d(TAG, "Loaded ${vocab?.size ?: 0} vocabulary entries") + true + } catch (e: Exception) { + Log.e(TAG, "Error loading vocabulary", e) + false + } + } + + /** + * Gets word bytes from token ID. + */ + fun getWordFromToken(token: Int): ByteArray { + return tokenToWord?.get(token) ?: byteArrayOf() + } + + /** + * Gets language code from language token (50259-50357). + */ + fun getLanguageFromToken(token: Int): String { + // Simplified mapping - in production, use full Whisper language token mapping + return when (token) { + 50259 -> "en" + 50260 -> "zh" + 50261 -> "de" + 50262 -> "es" + 50263 -> "ru" + 50264 -> "ko" + 50265 -> "fr" + 50266 -> "ja" + 50267 -> "pt" + 50268 -> "tr" + 50269 -> "pl" + 50270 -> "ca" + 50271 -> "nl" + 50272 -> "ar" + 50273 -> "sv" + 50274 -> "it" + 50275 -> "id" + 50276 -> "hi" + 50277 -> "fi" + 50278 -> "vi" + else -> "auto" + } + } + + /** + * Calculates Mel spectrogram from audio samples. + * This is a simplified implementation - production code should use + * optimized DSP libraries. + */ + fun getMelSpectrogram(samples: FloatArray, sampleCount: Int, numThreads: Int): FloatArray { + // Calculate number of frames + val numFrames = (sampleCount - N_FFT) / HOP_LENGTH + 1 + val melSpectrogram = FloatArray(N_MEL * numFrames) + + // Create Mel filter bank + val melFilterBank = createMelFilterBank() + + // Process each frame + for (frame in 0 until numFrames) { + val frameStart = frame * HOP_LENGTH + val frameEnd = minOf(frameStart + N_FFT, sampleCount) + + // Extract frame + val frameData = FloatArray(N_FFT) + for (i in 0 until (frameEnd - frameStart)) { + frameData[i] = samples[frameStart + i] + } + + // Apply Hann window + applyHannWindow(frameData) + + // Compute FFT (simplified - production should use FFT library) + val fftMagnitudes = computeFFTMagnitudes(frameData) + + // Apply Mel filter bank + for (mel in 0 until N_MEL) { + var melValue = 0f + for (k in fftMagnitudes.indices) { + melValue += fftMagnitudes[k] * melFilterBank[mel][k] + } + // Convert to log scale + melSpectrogram[frame * N_MEL + mel] = ln(maxOf(melValue, 1e-10f)) + } + } + + return melSpectrogram + } + + /** + * Creates Mel filter bank matrix. + */ + private fun createMelFilterBank(): Array { + val filterBank = Array(N_MEL) { FloatArray(N_FFT / 2 + 1) } + + val melLow = hzToMel(MEL_LOW_HZ) + val melHigh = hzToMel(MEL_HIGH_HZ) + val melStep = (melHigh - melLow) / (N_MEL + 1) + + val melPoints = FloatArray(N_MEL + 2) { i -> melLow + i * melStep } + val hzPoints = melPoints.map { melToHz(it) } + val binPoints = hzPoints.map { hz -> (N_FFT + 1) * hz / WHISPER_SAMPLE_RATE } + + for (mel in 0 until N_MEL) { + val left = binPoints[mel].toInt() + val center = binPoints[mel + 1].toInt() + val right = binPoints[mel + 2].toInt() + + // Rising slope + for (k in left until center) { + if (k < filterBank[mel].size) { + filterBank[mel][k] = (k - left).toFloat() / (center - left) + } + } + + // Falling slope + for (k in center until right) { + if (k < filterBank[mel].size) { + filterBank[mel][k] = (right - k).toFloat() / (right - center) + } + } + } + + return filterBank + } + + /** + * Converts Hz to Mel scale. + */ + private fun hzToMel(hz: Float): Float { + return 2595f * log10(1f + hz / 700f) + } + + /** + * Converts Mel to Hz scale. + */ + private fun melToHz(mel: Float): Float { + return 700f * (10f.pow(mel / 2595f) - 1f) + } + + /** + * Applies Hann window to frame. + */ + private fun applyHannWindow(frame: FloatArray) { + for (i in frame.indices) { + frame[i] *= 0.5f * (1f - cos(2f * PI.toFloat() * i / (frame.size - 1))) + } + } + + /** + * Computes FFT magnitudes (simplified DFT implementation). + * Production code should use optimized FFT library (e.g., JTransforms). + */ + private fun computeFFTMagnitudes(frame: FloatArray): FloatArray { + val n = frame.size + val magnitudes = FloatArray(n / 2 + 1) + + // Simplified DFT - compute only positive frequencies + for (k in magnitudes.indices) { + var real = 0f + var imag = 0f + + for (t in 0 until n) { + val angle = -2f * PI.toFloat() * k * t / n + real += frame[t] * cos(angle) + imag += frame[t] * sin(angle) + } + + magnitudes[k] = sqrt(real * real + imag * imag) + } + + return magnitudes + } +} + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 8161fc3e..1c6e33a3 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -231,6 +231,26 @@ Jetzt sprechen… + + Whisper verwenden (Offline) + Whisper für Offline-Spracherkennung verwenden statt Google + Whisper-Modell + Modell für Spracherkennungsqualität und -geschwindigkeit auswählen + Modelle herunterladen + Whisper-Modelle für Offline-Spracherkennung herunterladen + Verarbeite... + Whisper-Modell nicht gefunden. Bitte zuerst herunterladen. + Fehler beim Initialisieren der Whisper-Engine. + Whisper-Erkennungsfehler. + Tiny (nur Englisch) - 75 MB + Base (Mehrsprachig) - 150 MB + Small (Mehrsprachig) - 500 MB + Modell wird heruntergeladen... + Modell erfolgreich heruntergeladen + Modell-Download fehlgeschlagen + Heruntergeladen + Nicht heruntergeladen + Korrekturen suchen… Suchen diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e2f075d2..d6c76478 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -277,6 +277,26 @@ Speech recognition error. Speech recognition not available. + + Use Whisper (Offline) + Use Whisper for offline speech recognition instead of Google + Whisper Model + Select the model for speech recognition quality and speed + Download Models + Download Whisper models for offline speech recognition + Processing... + Whisper model not found. Please download it first. + Error initializing Whisper engine. + Whisper recognition error. + Tiny (English only) - 75 MB + Base (Multilingual) - 150 MB + Small (Multilingual) - 500 MB + Downloading model... + Model downloaded successfully + Model download failed + Downloaded + Not downloaded + Search corrections... Search From 7be4a22edb047a056c6beeefb7b9a5515f7a52c6 Mon Sep 17 00:00:00 2001 From: Patrick Zauner <74716024+pzauner@users.noreply.github.com> Date: Sun, 4 Jan 2026 02:23:49 +0100 Subject: [PATCH 02/14] fix: Add JitPack repository for android-vad dependency --- settings.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/settings.gradle.kts b/settings.gradle.kts index d5c33735..f3e92d5d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,6 +16,7 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { url = uri("https://jitpack.io") } } } From b926f22365cb1dcbeb4f1208e6a0b5478fd01c0a Mon Sep 17 00:00:00 2001 From: Patrick Zauner <74716024+pzauner@users.noreply.github.com> Date: Sun, 4 Jan 2026 02:24:52 +0100 Subject: [PATCH 03/14] feat: Add Whisper model downloader and prepare settings integration - Created WhisperModelDownloader for Hugging Face model downloads - Added Speech Recognition settings category (EN/DE) - Progress tracking for downloads - Atomic file writes with temp files - Shared vocab file management Next: Complete WhisperSettingsScreen UI and integrate into SettingsScreen --- app/build.properties | 6 +- .../whisper/WhisperModelDownloader.kt | 199 ++++++++++++++++++ app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 4 files changed, 204 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperModelDownloader.kt diff --git a/app/build.properties b/app/build.properties index a53387c4..12cd1020 100644 --- a/app/build.properties +++ b/app/build.properties @@ -1,4 +1,4 @@ #Build number and date -#Sat Jan 03 00:42:14 CET 2026 -buildDate=03 gen 2026 -buildNumber=1819 +#Sun Jan 04 02:23:12 CET 2026 +buildDate=04 gen 2026 +buildNumber=1820 diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperModelDownloader.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperModelDownloader.kt new file mode 100644 index 00000000..ba0ecf3d --- /dev/null +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperModelDownloader.kt @@ -0,0 +1,199 @@ +package it.palsoftware.pastiera.inputmethod.whisper + +import android.content.Context +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File +import java.io.FileOutputStream +import java.util.concurrent.TimeUnit + +/** + * Downloads Whisper models from Hugging Face. + */ +class WhisperModelDownloader(private val context: Context) { + companion object { + private const val TAG = "WhisperModelDownloader" + private const val MODELS_DIR = "whisper_models" + private const val CONNECT_TIMEOUT_SECONDS = 30L + private const val READ_TIMEOUT_SECONDS = 300L // 5 minutes for large downloads + } + + interface DownloadListener { + fun onProgress(bytesDownloaded: Long, totalBytes: Long) + fun onComplete(file: File) + fun onError(error: String) + } + + private val client = OkHttpClient.Builder() + .connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .build() + + /** + * Gets the models directory, creating it if needed. + */ + private fun getModelsDir(): File { + val dir = File(context.getExternalFilesDir(null), MODELS_DIR) + if (!dir.exists()) { + dir.mkdirs() + } + return dir + } + + /** + * Checks if a model is downloaded. + */ + fun isModelDownloaded(model: WhisperModel): Boolean { + val modelFile = File(getModelsDir(), model.fileName) + val vocabFile = getVocabFile(model) + return modelFile.exists() && vocabFile.exists() && + modelFile.length() > 0 && vocabFile.length() > 0 + } + + /** + * Gets the vocab file for a model. + */ + private fun getVocabFile(model: WhisperModel): File { + val vocabFileName = if (model.isMultilingual) { + "filters_vocab_multilingual.bin" + } else { + "filters_vocab_en.bin" + } + return File(getModelsDir(), vocabFileName) + } + + /** + * Gets the size of downloaded model files. + */ + fun getDownloadedSize(model: WhisperModel): Long { + val modelFile = File(getModelsDir(), model.fileName) + val vocabFile = getVocabFile(model) + return (if (modelFile.exists()) modelFile.length() else 0) + + (if (vocabFile.exists()) vocabFile.length() else 0) + } + + /** + * Downloads a Whisper model and its vocab file. + */ + suspend fun downloadModel( + model: WhisperModel, + listener: DownloadListener? = null + ): Result = withContext(Dispatchers.IO) { + try { + Log.d(TAG, "Starting download of ${model.displayName}") + + val modelsDir = getModelsDir() + val modelFile = File(modelsDir, model.fileName) + val vocabFile = getVocabFile(model) + + // Download vocab file if not present + if (!vocabFile.exists() || vocabFile.length() == 0L) { + val vocabUrl = if (model.isMultilingual) { + WhisperModelUrls.VOCAB_MULTILINGUAL_URL + } else { + WhisperModelUrls.VOCAB_EN_URL + } + + Log.d(TAG, "Downloading vocab file from $vocabUrl") + downloadFile(vocabUrl, vocabFile, null) // Vocab is small, no progress + } + + // Download model file + val modelUrl = WhisperModelUrls.getDownloadUrl(model) + Log.d(TAG, "Downloading model from $modelUrl") + downloadFile(modelUrl, modelFile, listener) + + Log.d(TAG, "Download complete: ${modelFile.absolutePath}") + Result.success(modelFile) + } catch (e: Exception) { + Log.e(TAG, "Error downloading model", e) + listener?.onError(e.message ?: "Download failed") + Result.failure(e) + } + } + + /** + * Downloads a file from a URL with progress tracking. + */ + private fun downloadFile( + url: String, + targetFile: File, + listener: DownloadListener? + ) { + val request = Request.Builder() + .url(url) + .build() + + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw Exception("Download failed: HTTP ${response.code}") + } + + val body = response.body ?: throw Exception("Empty response body") + val contentLength = body.contentLength() + + // Create temp file for atomic write + val tempFile = File(targetFile.parentFile, "${targetFile.name}.tmp") + + FileOutputStream(tempFile).use { output -> + val buffer = ByteArray(8192) + var bytesRead: Int + var totalBytesRead = 0L + + body.byteStream().use { input -> + while (input.read(buffer).also { bytesRead = it } != -1) { + output.write(buffer, 0, bytesRead) + totalBytesRead += bytesRead + + listener?.onProgress(totalBytesRead, contentLength) + } + } + } + + // Verify download + if (contentLength > 0 && tempFile.length() != contentLength) { + tempFile.delete() + throw Exception("Download incomplete: ${tempFile.length()} of $contentLength bytes") + } + + // Move temp file to target + if (targetFile.exists()) { + targetFile.delete() + } + tempFile.renameTo(targetFile) + + listener?.onComplete(targetFile) + } + } + + /** + * Deletes a downloaded model. + */ + fun deleteModel(model: WhisperModel): Boolean { + return try { + val modelFile = File(getModelsDir(), model.fileName) + val deleted = if (modelFile.exists()) modelFile.delete() else true + + // Note: Don't delete vocab files as they might be shared + // between models (multilingual vocab is shared) + + Log.d(TAG, "Deleted model: ${model.displayName}") + deleted + } catch (e: Exception) { + Log.e(TAG, "Error deleting model", e) + false + } + } + + /** + * Gets total size of all downloaded models. + */ + fun getTotalDownloadedSize(): Long { + val modelsDir = getModelsDir() + return modelsDir.listFiles()?.sumOf { it.length() } ?: 0L + } +} + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 1c6e33a3..9e168b49 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -320,6 +320,7 @@ Texteingabe Autokorrektur Anpassung + Spracherkennung Erweitert diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d6c76478..4bc98483 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -390,6 +390,7 @@ Smart Features Auto-correction Customization + Speech Recognition Advanced From 681ffd29520d66c0b79562055bb81fd047567284 Mon Sep 17 00:00:00 2001 From: Patrick Zauner <74716024+pzauner@users.noreply.github.com> Date: Sun, 4 Jan 2026 02:29:58 +0100 Subject: [PATCH 04/14] fix: Correct VAD API usage in WhisperRecorder - Fixed VAD initialization (VadWebRTC instead of Vad) - Removed incorrect listener API (use isSpeech() directly) - Changed audio buffer from FloatArray to ByteArray (PCM16) - Aligned with Whisper+ implementation - Proper VAD frame size handling (480 samples = 30ms) --- app/build.properties | 4 +- .../whisper/WhisperRecordBuffer.kt | 23 ++-- .../inputmethod/whisper/WhisperRecorder.kt | 111 +++++++++--------- 3 files changed, 71 insertions(+), 67 deletions(-) diff --git a/app/build.properties b/app/build.properties index 12cd1020..42a23543 100644 --- a/app/build.properties +++ b/app/build.properties @@ -1,4 +1,4 @@ #Build number and date -#Sun Jan 04 02:23:12 CET 2026 +#Sun Jan 04 02:27:44 CET 2026 buildDate=04 gen 2026 -buildNumber=1820 +buildNumber=1821 diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperRecordBuffer.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperRecordBuffer.kt index c5928a86..cf3c70d4 100644 --- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperRecordBuffer.kt +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperRecordBuffer.kt @@ -8,27 +8,28 @@ import java.nio.ByteOrder * Stores PCM audio samples and provides conversion utilities. */ object WhisperRecordBuffer { - private var outputBuffer: FloatArray? = null + private var outputBuffer: ByteArray? = null /** - * Sets the output buffer with PCM_FLOAT samples. + * Sets the output buffer with PCM_16BIT samples as ByteArray. */ - fun setOutputBuffer(buffer: FloatArray) { + fun setOutputBuffer(buffer: ByteArray) { outputBuffer = buffer } /** - * Gets the output buffer with PCM_FLOAT samples. + * Gets the output buffer as ByteArray. */ - fun getOutputBuffer(): FloatArray? { + fun getOutputBuffer(): ByteArray? { return outputBuffer } /** - * Gets samples as FloatArray for processing. + * Gets samples as FloatArray for Whisper processing (normalized to [-1.0, 1.0]). */ fun getSamples(): FloatArray? { - return outputBuffer + val bytes = outputBuffer ?: return null + return convertPCM16ToFloat(bytes) } /** @@ -39,11 +40,11 @@ object WhisperRecordBuffer { } /** - * Converts PCM16 ByteBuffer to FloatArray (normalized to [-1.0, 1.0]). + * Converts PCM16 ByteArray to FloatArray (normalized to [-1.0, 1.0]). */ - fun convertPCM16ToFloat(pcm16Buffer: ByteBuffer): FloatArray { - pcm16Buffer.order(ByteOrder.LITTLE_ENDIAN) - val shortBuffer = pcm16Buffer.asShortBuffer() + private fun convertPCM16ToFloat(pcm16Bytes: ByteArray): FloatArray { + val byteBuffer = ByteBuffer.wrap(pcm16Bytes).order(ByteOrder.LITTLE_ENDIAN) + val shortBuffer = byteBuffer.asShortBuffer() val floatBuffer = FloatArray(shortBuffer.remaining()) for (i in floatBuffer.indices) { diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperRecorder.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperRecorder.kt index 595cf9ff..9c1d6ca4 100644 --- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperRecorder.kt +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperRecorder.kt @@ -6,7 +6,7 @@ import android.media.AudioRecord import android.media.MediaRecorder import android.util.Log import com.konovalov.vad.webrtc.Vad -import com.konovalov.vad.webrtc.VadListener +import com.konovalov.vad.webrtc.VadWebRTC import com.konovalov.vad.webrtc.config.FrameSize import com.konovalov.vad.webrtc.config.Mode import com.konovalov.vad.webrtc.config.SampleRate @@ -14,7 +14,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import java.nio.ByteBuffer import java.nio.ByteOrder @@ -30,7 +29,8 @@ class WhisperRecorder(private val context: Context) { private const val CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO private const val AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT private const val MAX_RECORDING_DURATION_MS = 30000L // 30 seconds max - private const val SILENCE_DURATION_MS = 1500L // Stop after 1.5s of silence + private const val SILENCE_DURATION_MS = 800L // Stop after 800ms of silence (like Whisper+) + private const val VAD_FRAME_SIZE = 480 // 30ms at 16kHz } interface RecorderListener { @@ -42,11 +42,11 @@ class WhisperRecorder(private val context: Context) { private var audioRecord: AudioRecord? = null private var recordingJob: Job? = null - private var vad: Vad? = null + private var vad: VadWebRTC? = null private var listener: RecorderListener? = null private var isRecording = false - private val audioBuffer = mutableListOf() - private var lastSpeechTime = 0L + private val audioBuffer = mutableListOf() + private var speechDetected = false private var recordingStartTime = 0L fun setListener(listener: RecorderListener) { @@ -61,25 +61,12 @@ class WhisperRecorder(private val context: Context) { vad = Vad.builder() .setSampleRate(SampleRate.SAMPLE_RATE_16K) .setFrameSize(FrameSize.FRAME_SIZE_480) // 30ms frames - .setMode(Mode.NORMAL) // Balance between accuracy and CPU - .setSilenceDurationMillis(500) // Detect silence after 500ms - .setSpeechDurationMillis(100) // Minimum speech duration - .setContext(context) + .setMode(Mode.VERY_AGGRESSIVE) // Aggressive mode like Whisper+ + .setSilenceDurationMs(SILENCE_DURATION_MS.toInt()) + .setSpeechDurationMs(200) // Minimum speech duration .build() - vad?.addListener(object : VadListener { - override fun onSpeechDetected() { - Log.d(TAG, "VAD: Speech detected") - lastSpeechTime = System.currentTimeMillis() - } - - override fun onNoiseDetected() { - Log.d(TAG, "VAD: Noise detected (no speech)") - } - }) - - vad?.start() - Log.d(TAG, "VAD initialized and started") + Log.d(TAG, "VAD initialized") } catch (e: Exception) { Log.e(TAG, "Error initializing VAD", e) } @@ -106,12 +93,14 @@ class WhisperRecorder(private val context: Context) { return } + val finalBufferSize = maxOf(bufferSize, VAD_FRAME_SIZE * 2) + audioRecord = AudioRecord( MediaRecorder.AudioSource.VOICE_RECOGNITION, SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT, - bufferSize * 2 // Double buffer for safety + finalBufferSize * 2 // Double buffer for safety ) if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) { @@ -120,10 +109,10 @@ class WhisperRecorder(private val context: Context) { } audioBuffer.clear() + speechDetected = false audioRecord?.startRecording() isRecording = true recordingStartTime = System.currentTimeMillis() - lastSpeechTime = recordingStartTime listener?.onRecordingStarted() // Start recording loop in coroutine @@ -145,32 +134,47 @@ class WhisperRecorder(private val context: Context) { * Recording loop that reads audio data and processes it with VAD. */ private suspend fun recordAudio() { - val bufferSize = AudioRecord.getMinBufferSize( - SAMPLE_RATE, - CHANNEL_CONFIG, - AUDIO_FORMAT - ) - val buffer = ShortArray(bufferSize / 2) // PCM16 = 2 bytes per sample + val vadBuffer = ByteArray(VAD_FRAME_SIZE * 2) // 16-bit samples + val readBuffer = ByteArray(VAD_FRAME_SIZE * 2) + var totalBytesRead = 0 + val maxBytes = SAMPLE_RATE * 2 * 30 // 30 seconds at 16kHz, 16-bit - while (isRecording && isActive) { - val readCount = audioRecord?.read(buffer, 0, buffer.size) ?: 0 + while (isRecording && totalBytesRead < maxBytes) { + val bytesRead = audioRecord?.read(readBuffer, 0, readBuffer.size) ?: 0 - if (readCount > 0) { + if (bytesRead > 0) { // Add to buffer - audioBuffer.addAll(buffer.take(readCount)) + audioBuffer.addAll(readBuffer.take(bytesRead).toList()) + totalBytesRead += bytesRead - // Process with VAD - vad?.setContinuousSpeechListener(buffer, readCount) { speechActive -> - if (speechActive) { - lastSpeechTime = System.currentTimeMillis() + // Process with VAD if we have enough data + if (vad != null && audioBuffer.size >= VAD_FRAME_SIZE * 2) { + // Get last VAD_FRAME_SIZE * 2 bytes for VAD analysis + val startIdx = audioBuffer.size - VAD_FRAME_SIZE * 2 + for (i in 0 until VAD_FRAME_SIZE * 2) { + vadBuffer[i] = audioBuffer[startIdx + i] + } + + val isSpeech = vad?.isSpeech(vadBuffer) ?: false + + if (isSpeech) { + if (!speechDetected) { + Log.d(TAG, "VAD: Speech detected, recording starts") + speechDetected = true + } + } else { + if (speechDetected) { + Log.d(TAG, "VAD: Silence detected after speech, stopping") + stop() + break + } } } listener?.onRecording() - // Check if we should stop recording + // Check max duration val currentTime = System.currentTimeMillis() - val silenceDuration = currentTime - lastSpeechTime val totalDuration = currentTime - recordingStartTime if (totalDuration >= MAX_RECORDING_DURATION_MS) { @@ -178,14 +182,8 @@ class WhisperRecorder(private val context: Context) { stop() break } - - if (silenceDuration >= SILENCE_DURATION_MS && audioBuffer.isNotEmpty()) { - Log.d(TAG, "Silence detected, stopping recording") - stop() - break - } - } else if (readCount < 0) { - Log.e(TAG, "Error reading audio: $readCount") + } else if (bytesRead < 0) { + Log.e(TAG, "Error reading audio: $bytesRead") break } @@ -211,16 +209,21 @@ class WhisperRecorder(private val context: Context) { audioRecord?.release() audioRecord = null - vad?.stop() vad?.close() vad = null - // Convert buffer to FloatArray and store in WhisperRecordBuffer - val floatSamples = audioBuffer.map { it / 32768.0f }.toFloatArray() - WhisperRecordBuffer.setOutputBuffer(floatSamples) + // Check minimum recording length (0.2s = 6400 bytes at 16kHz 16-bit) + if (audioBuffer.size < 6400) { + listener?.onRecordingError("Recording too short") + Log.w(TAG, "Recording too short: ${audioBuffer.size} bytes") + return + } + + // Convert ByteArray to FloatArray and store in WhisperRecordBuffer + WhisperRecordBuffer.setOutputBuffer(audioBuffer.toByteArray()) listener?.onRecordingDone() - Log.d(TAG, "Recording stopped. Captured ${audioBuffer.size} samples (${audioBuffer.size / SAMPLE_RATE}s)") + Log.d(TAG, "Recording stopped. Captured ${audioBuffer.size} bytes (${audioBuffer.size / (SAMPLE_RATE * 2)}s)") } catch (e: Exception) { Log.e(TAG, "Error stopping recording", e) listener?.onRecordingError(e.message ?: "Unknown error") From d9d20b179734930b2d42c02ff15b42988086992b Mon Sep 17 00:00:00 2001 From: Patrick Zauner <74716024+pzauner@users.noreply.github.com> Date: Sun, 4 Jan 2026 02:48:04 +0100 Subject: [PATCH 05/14] Fix: Correct VAD API usage and SettingsScreen navigation - Fix WhisperRecorder.kt to use correct Vad.builder() API - Fix WhisperRecordBuffer.kt to use ByteArray instead of FloatArray - Fix SettingsScreen.kt indentation and brace issues - Add Speech Recognition navigation to main settings - All menu items now properly visible in settings --- app/build.properties | 4 +- .../it/palsoftware/pastiera/SettingsScreen.kt | 561 +++++++------- .../pastiera/SettingsScreen.kt.bak | 694 ++++++++++++++++++ .../pastiera/WhisperSettingsScreen.kt | 268 +++++++ 4 files changed, 1268 insertions(+), 259 deletions(-) create mode 100644 app/src/main/java/it/palsoftware/pastiera/SettingsScreen.kt.bak create mode 100644 app/src/main/java/it/palsoftware/pastiera/WhisperSettingsScreen.kt diff --git a/app/build.properties b/app/build.properties index 42a23543..06cff8f5 100644 --- a/app/build.properties +++ b/app/build.properties @@ -1,4 +1,4 @@ #Build number and date -#Sun Jan 04 02:27:44 CET 2026 +#Sun Jan 04 02:46:20 CET 2026 buildDate=04 gen 2026 -buildNumber=1821 +buildNumber=1835 diff --git a/app/src/main/java/it/palsoftware/pastiera/SettingsScreen.kt b/app/src/main/java/it/palsoftware/pastiera/SettingsScreen.kt index 9bd74bef..60809b7e 100644 --- a/app/src/main/java/it/palsoftware/pastiera/SettingsScreen.kt +++ b/app/src/main/java/it/palsoftware/pastiera/SettingsScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Code import androidx.compose.material.icons.filled.TouchApp import androidx.compose.material.icons.filled.Spellcheck +import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Tune import androidx.compose.material.icons.filled.Engineering import androidx.activity.compose.BackHandler @@ -53,6 +54,7 @@ sealed class SettingsDestination { object KeyboardTiming : SettingsDestination() object TextInput : SettingsDestination() object AutoCorrection : SettingsDestination() + object SpeechRecognition : SettingsDestination() object Customization : SettingsDestination() object Advanced : SettingsDestination() object About : SettingsDestination() @@ -145,6 +147,7 @@ fun SettingsScreen( onKeyboardTimingClick = { navigateTo(SettingsDestination.KeyboardTiming) }, onTextInputClick = { navigateTo(SettingsDestination.TextInput) }, onAutoCorrectionClick = { navigateTo(SettingsDestination.AutoCorrection) }, + onSpeechRecognitionClick = { navigateTo(SettingsDestination.SpeechRecognition) }, onCustomizationClick = { navigateTo(SettingsDestination.Customization) }, onAdvancedClick = { navigateTo(SettingsDestination.Advanced) }, onAboutClick = { navigateTo(SettingsDestination.About) }, @@ -170,6 +173,12 @@ fun SettingsScreen( onBack = { navigateBack() } ) } + is SettingsDestination.SpeechRecognition -> { + WhisperSettingsScreen( + modifier = modifier, + onBack = { navigateBack() } + ) + } is SettingsDestination.Customization -> { CustomizationSettingsScreen( modifier = modifier, @@ -212,6 +221,7 @@ private fun SettingsMainScreen( onKeyboardTimingClick: () -> Unit, onTextInputClick: () -> Unit, onAutoCorrectionClick: () -> Unit, + onSpeechRecognitionClick: () -> Unit, onCustomizationClick: () -> Unit, onAdvancedClick: () -> Unit, onAboutClick: () -> Unit, @@ -261,263 +271,299 @@ private fun SettingsMainScreen( .height(64.dp) .clickable(onClick = onKeyboardTimingClick) ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Icon( - imageVector = Icons.Filled.Keyboard, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp) - ) - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(R.string.settings_category_keyboard_timing), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium, - maxLines = 1 - ) - } - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowForward, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = Icons.Filled.Keyboard, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.settings_category_keyboard_timing), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + maxLines = 1 ) } + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) } + } - // Text Input - Surface( + // Text Input + Surface( + modifier = Modifier + .fillMaxWidth() + .height(64.dp) + .clickable(onClick = onTextInputClick) + ) { + Row( modifier = Modifier .fillMaxWidth() - .height(64.dp) - .clickable(onClick = onTextInputClick) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Icon( - imageVector = Icons.Filled.TextFields, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp) - ) - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(R.string.settings_category_text_input), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium, - maxLines = 1 - ) - } - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowForward, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant + Icon( + imageVector = Icons.Filled.TextFields, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.settings_category_text_input), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + maxLines = 1 ) } + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) } + } - // Languages and Maps (Custom Input Styles) - Surface( + // Languages and Maps (Custom Input Styles) + Surface( + modifier = Modifier + .fillMaxWidth() + .height(64.dp) + .clickable(onClick = onCustomInputStylesClick) + ) { + Row( modifier = Modifier .fillMaxWidth() - .height(64.dp) - .clickable(onClick = onCustomInputStylesClick) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Icon( - imageVector = Icons.Filled.Language, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp) - ) - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(R.string.custom_input_styles_title), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium, - maxLines = 1 - ) - } - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowForward, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant + Icon( + imageVector = Icons.Filled.Language, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.custom_input_styles_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + maxLines = 1 ) } + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) } + } - // Auto-correction - Surface( + // Auto-correction + Surface( + modifier = Modifier + .fillMaxWidth() + .height(64.dp) + .clickable(onClick = onAutoCorrectionClick) + ) { + Row( modifier = Modifier .fillMaxWidth() - .height(64.dp) - .clickable(onClick = onAutoCorrectionClick) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Icon( - imageVector = Icons.Filled.Spellcheck, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp) - ) - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(R.string.settings_category_auto_correction), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium, - maxLines = 1 - ) - } - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowForward, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant + Icon( + imageVector = Icons.Filled.Spellcheck, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.settings_category_auto_correction), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + maxLines = 1 ) } + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) } + } - // Customization - Surface( + // Speech Recognition + Surface( + modifier = Modifier + .fillMaxWidth() + .height(64.dp) + .clickable { onSpeechRecognitionClick() } + ) { + Row( modifier = Modifier .fillMaxWidth() - .height(64.dp) - .clickable(onClick = onCustomizationClick) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Icon( - imageVector = Icons.Filled.Tune, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp) - ) - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(R.string.settings_category_customization), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium, - maxLines = 1 - ) - } - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowForward, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant + Icon( + imageVector = Icons.Filled.Mic, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.settings_category_speech_recognition), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + maxLines = 1 ) } + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) } + } - // Advanced - Surface( + // Customization + Surface( + modifier = Modifier + .fillMaxWidth() + .height(64.dp) + .clickable(onClick = onCustomizationClick) + ) { + Row( modifier = Modifier .fillMaxWidth() - .height(64.dp) - .clickable(onClick = onAdvancedClick) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Icon( - imageVector = Icons.Filled.Engineering, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp) - ) - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(R.string.settings_category_advanced), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium, - maxLines = 1 - ) - } - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowForward, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant + Icon( + imageVector = Icons.Filled.Tune, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.settings_category_customization), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + maxLines = 1 ) } + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) } + } - // About section - Surface( + // Advanced + Surface( + modifier = Modifier + .fillMaxWidth() + .height(64.dp) + .clickable(onClick = onAdvancedClick) + ) { + Row( modifier = Modifier .fillMaxWidth() - .height(64.dp) - .clickable(onClick = onAboutClick) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Icon( - imageVector = Icons.Filled.Info, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp) - ) - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(R.string.about_title), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium, - maxLines = 1 - ) - } - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowForward, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant + Icon( + imageVector = Icons.Filled.Engineering, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.settings_category_advanced), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + maxLines = 1 ) } + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) } - - Spacer(modifier = Modifier.height(16.dp)) - - Surface( + } + + // About section + Surface( + modifier = Modifier + .fillMaxWidth() + .height(64.dp) + .clickable(onClick = onAboutClick) + ) { + Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), - tonalElevation = 0.dp, - shadowElevation = 0.dp, - shape = MaterialTheme.shapes.extraSmall, - color = MaterialTheme.colorScheme.surfaceVariant + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - Column( + Icon( + imageVector = Icons.Filled.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.about_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + maxLines = 1 + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + shape = MaterialTheme.shapes.extraSmall, + color = MaterialTheme.colorScheme.surfaceVariant + ) { + Column( modifier = Modifier .fillMaxWidth() .padding(vertical = 16.dp, horizontal = 12.dp) @@ -584,63 +630,64 @@ private fun SettingsMainScreen( } } - // Build Info - Surface( + // Build Info + Surface( + modifier = Modifier + .fillMaxWidth() + .height(64.dp) + ) { + Row( modifier = Modifier .fillMaxWidth() - .height(64.dp) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Icon( - imageVector = Icons.Filled.Info, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp) + Icon( + imageVector = Icons.Filled.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.about_build_info), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + maxLines = 1 + ) + Text( + text = BuildInfo.getBuildInfoString(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1 ) - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(R.string.about_build_info), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium, - maxLines = 1 - ) - Text( - text = BuildInfo.getBuildInfoString(), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1 - ) - } } } + } - // Ko-fi Support Link - Box( + // Ko-fi Support Link + Box( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .clickable { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://ko-fi.com/palsoftware")) + context.startActivity(intent) + }, + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(id = R.drawable.kofi5), + contentDescription = stringResource(R.string.settings_support_ko_fi), modifier = Modifier - .fillMaxWidth() - .height(56.dp) - .clickable { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://ko-fi.com/palsoftware")) - context.startActivity(intent) - }, - contentAlignment = Alignment.Center - ) { - Image( - painter = painterResource(id = R.drawable.kofi5), - contentDescription = stringResource(R.string.settings_support_ko_fi), - modifier = Modifier - .fillMaxWidth(0.35f) - .aspectRatio(1f) - ) - } - - Spacer(modifier = Modifier.height(16.dp)) + .fillMaxWidth(0.35f) + .aspectRatio(1f) + ) } + + Spacer(modifier = Modifier.height(16.dp)) } } +} + diff --git a/app/src/main/java/it/palsoftware/pastiera/SettingsScreen.kt.bak b/app/src/main/java/it/palsoftware/pastiera/SettingsScreen.kt.bak new file mode 100644 index 00000000..8221d468 --- /dev/null +++ b/app/src/main/java/it/palsoftware/pastiera/SettingsScreen.kt.bak @@ -0,0 +1,694 @@ +package it.palsoftware.pastiera + +import android.content.Context +import android.content.Intent +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.Keyboard +import androidx.compose.material.icons.filled.Timer +import androidx.compose.material.icons.filled.TextFields +import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.TouchApp +import androidx.compose.material.icons.filled.Spellcheck +import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material.icons.filled.Engineering +import androidx.activity.compose.BackHandler +import androidx.activity.ComponentActivity +import androidx.compose.animation.* +import androidx.compose.animation.core.tween +import android.net.Uri +import androidx.compose.foundation.Image +import androidx.compose.ui.res.painterResource +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsPadding +import it.palsoftware.pastiera.R +import android.widget.Toast +import it.palsoftware.pastiera.BuildConfig +import it.palsoftware.pastiera.update.checkForUpdate +import it.palsoftware.pastiera.update.showUpdateDialog + +/** + * Sealed class per rappresentare lo stato della navigazione nelle settings. + */ +sealed class SettingsDestination { + object Main : SettingsDestination() + object KeyboardTiming : SettingsDestination() + object TextInput : SettingsDestination() + object AutoCorrection : SettingsDestination() + object SpeechRecognition : SettingsDestination() + object Customization : SettingsDestination() + object Advanced : SettingsDestination() + object About : SettingsDestination() + object CustomInputStyles : SettingsDestination() +} + +/** + * App settings screen. + */ +@Composable +fun SettingsScreen( + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val activity = context as? ComponentActivity + + var checkingForUpdates by remember { mutableStateOf(false) } + var navigationDirection by remember { mutableStateOf(NavigationDirection.Push) } + val navigationStack = remember { + mutableStateListOf(SettingsDestination.Main) + } + val currentDestination by remember { + derivedStateOf { navigationStack.last() } + } + + fun navigateTo(destination: SettingsDestination) { + if (currentDestination == destination) return + navigationDirection = NavigationDirection.Push + navigationStack.add(destination) + } + + fun navigateBack() { + if (navigationStack.size > 1) { + navigationDirection = NavigationDirection.Pop + navigationStack.removeAt(navigationStack.lastIndex) + } else { + activity?.finish() + } + } + + // Automatic update check on screen open (only once, respecting dismissed releases) + LaunchedEffect(Unit) { + checkForUpdate( + context = context, + currentVersion = BuildConfig.VERSION_NAME, + ignoreDismissedReleases = true + ) { hasUpdate, latestVersion, downloadUrl -> + if (hasUpdate && latestVersion != null) { + showUpdateDialog(context, latestVersion, downloadUrl) + } + } + } + + // Handle system back button + BackHandler { navigateBack() } + + AnimatedContent( + targetState = currentDestination, + transitionSpec = { + if (navigationDirection == NavigationDirection.Push) { + // Forward navigation: new screen enters from right, old screen exits to left + slideInHorizontally( + initialOffsetX = { fullWidth -> fullWidth }, + animationSpec = tween(250) + ) togetherWith slideOutHorizontally( + targetOffsetX = { fullWidth -> -fullWidth }, + animationSpec = tween(250) + ) + } else { + // Back navigation: current screen exits to right, previous screen enters from left + slideInHorizontally( + initialOffsetX = { fullWidth -> -fullWidth }, + animationSpec = tween(250) + ) togetherWith slideOutHorizontally( + targetOffsetX = { fullWidth -> fullWidth }, + animationSpec = tween(250) + ) + } + }, + label = "settings_navigation", + contentKey = { it::class } + ) { destination -> + when (destination) { + is SettingsDestination.Main -> { + SettingsMainScreen( + modifier = modifier, + context = context, + checkingForUpdates = checkingForUpdates, + onCheckingForUpdatesChange = { checkingForUpdates = it }, + onKeyboardTimingClick = { navigateTo(SettingsDestination.KeyboardTiming) }, + onTextInputClick = { navigateTo(SettingsDestination.TextInput) }, + onAutoCorrectionClick = { navigateTo(SettingsDestination.AutoCorrection) }, + onSpeechRecognitionClick = { navigateTo(SettingsDestination.SpeechRecognition) }, + onCustomizationClick = { navigateTo(SettingsDestination.Customization) }, + onAdvancedClick = { navigateTo(SettingsDestination.Advanced) }, + onAboutClick = { navigateTo(SettingsDestination.About) }, + onBackClick = { navigateBack() }, + onCustomInputStylesClick = { navigateTo(SettingsDestination.CustomInputStyles) } + ) + } + is SettingsDestination.KeyboardTiming -> { + KeyboardTimingSettingsScreen( + modifier = modifier, + onBack = { navigateBack() } + ) + } + is SettingsDestination.TextInput -> { + TextInputSettingsScreen( + modifier = modifier, + onBack = { navigateBack() } + ) + } + is SettingsDestination.AutoCorrection -> { + AutoCorrectionCategoryScreen( + modifier = modifier, + onBack = { navigateBack() } + ) + } + is SettingsDestination.SpeechRecognition -> { + WhisperSettingsScreen( + modifier = modifier, + onBack = { navigateBack() } + ) + } + is SettingsDestination.Customization -> { + CustomizationSettingsScreen( + modifier = modifier, + onBack = { navigateBack() } + ) + } + is SettingsDestination.Advanced -> { + AdvancedSettingsScreen( + modifier = modifier, + onBack = { navigateBack() } + ) + } + is SettingsDestination.About -> { + AboutScreen( + modifier = modifier, + onBack = { navigateBack() } + ) + } + is SettingsDestination.CustomInputStyles -> { + CustomInputStylesScreen( + modifier = modifier, + onBack = { navigateBack() } + ) + } + } + } +} + +private enum class NavigationDirection { + Push, + Pop +} + +@Composable +private fun SettingsMainScreen( + modifier: Modifier, + context: Context, + checkingForUpdates: Boolean, + onCheckingForUpdatesChange: (Boolean) -> Unit, + onKeyboardTimingClick: () -> Unit, + onTextInputClick: () -> Unit, + onAutoCorrectionClick: () -> Unit, + onSpeechRecognitionClick: () -> Unit, + onCustomizationClick: () -> Unit, + onAdvancedClick: () -> Unit, + onAboutClick: () -> Unit, + onBackClick: () -> Unit, + onCustomInputStylesClick: () -> Unit +) { + Scaffold( + topBar = { + Surface( + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.statusBars), + tonalElevation = 1.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.settings_back_content_description) + ) + } + Text( + text = stringResource(R.string.settings_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(start = 8.dp) + ) + } + } + } + ) { paddingValues -> + Column( + modifier = modifier + .fillMaxWidth() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + ) { + // Keyboard & Timing + Surface( + modifier = Modifier + .fillMaxWidth() + .height(64.dp) + .clickable(onClick = onKeyboardTimingClick) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = Icons.Filled.Keyboard, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.settings_category_keyboard_timing), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + maxLines = 1 + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Text Input + Surface( + modifier = Modifier + .fillMaxWidth() + .height(64.dp) + .clickable(onClick = onTextInputClick) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = Icons.Filled.TextFields, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.settings_category_text_input), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + maxLines = 1 + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Languages and Maps (Custom Input Styles) + Surface( + modifier = Modifier + .fillMaxWidth() + .height(64.dp) + .clickable(onClick = onCustomInputStylesClick) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = Icons.Filled.Language, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.custom_input_styles_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + maxLines = 1 + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Auto-correction + Surface( + modifier = Modifier + .fillMaxWidth() + .height(64.dp) + .clickable(onClick = onAutoCorrectionClick) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = Icons.Filled.Spellcheck, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.settings_category_auto_correction), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + maxLines = 1 + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Speech Recognition + Surface( + modifier = Modifier + .fillMaxWidth() + .height(64.dp) + .clickable { onSpeechRecognitionClick() } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = Icons.Filled.Mic, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.settings_category_speech_recognition), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + maxLines = 1 + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Customization + Surface( + modifier = Modifier + .fillMaxWidth() + .height(64.dp) + .clickable(onClick = onCustomizationClick) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = Icons.Filled.Tune, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.settings_category_customization), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + maxLines = 1 + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Advanced + Surface( + modifier = Modifier + .fillMaxWidth() + .height(64.dp) + .clickable(onClick = onAdvancedClick) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = Icons.Filled.Engineering, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.settings_category_advanced), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + maxLines = 1 + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // About section + Surface( + modifier = Modifier + .fillMaxWidth() + .height(64.dp) + .clickable(onClick = onAboutClick) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = Icons.Filled.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.about_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + maxLines = 1 + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + shape = MaterialTheme.shapes.extraSmall, + color = MaterialTheme.colorScheme.surfaceVariant + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp, horizontal = 12.dp) + ) { + Text( + text = stringResource(R.string.settings_update_section_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + Text( + text = stringResource(R.string.settings_update_section_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { + onCheckingForUpdatesChange(true) + checkForUpdate( + context = context, + currentVersion = BuildConfig.VERSION_NAME, + ignoreDismissedReleases = false + ) { hasUpdate, latestVersion, downloadUrl -> + onCheckingForUpdatesChange(false) + when { + latestVersion == null -> { + Toast.makeText( + context, + context.getString(R.string.settings_update_check_failed), + Toast.LENGTH_SHORT + ).show() + } + hasUpdate -> showUpdateDialog(context, latestVersion, downloadUrl) + else -> { + Toast.makeText( + context, + context.getString(R.string.settings_update_up_to_date), + Toast.LENGTH_SHORT + ).show() + } + } + } + }, + enabled = !checkingForUpdates, + modifier = Modifier.fillMaxWidth() + ) { + if (checkingForUpdates) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.settings_update_checking)) + } + } else { + Text(stringResource(R.string.settings_update_button)) + } + } + } + } + } + + // Build Info + Surface( + modifier = Modifier + .fillMaxWidth() + .height(64.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = Icons.Filled.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.about_build_info), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + maxLines = 1 + ) + Text( + text = BuildInfo.getBuildInfoString(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1 + ) + } + } + } + + // Ko-fi Support Link + Box( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .clickable { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://ko-fi.com/palsoftware")) + context.startActivity(intent) + }, + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(id = R.drawable.kofi5), + contentDescription = stringResource(R.string.settings_support_ko_fi), + modifier = Modifier + .fillMaxWidth(0.35f) + .aspectRatio(1f) + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + diff --git a/app/src/main/java/it/palsoftware/pastiera/WhisperSettingsScreen.kt b/app/src/main/java/it/palsoftware/pastiera/WhisperSettingsScreen.kt new file mode 100644 index 00000000..b44679bf --- /dev/null +++ b/app/src/main/java/it/palsoftware/pastiera/WhisperSettingsScreen.kt @@ -0,0 +1,268 @@ +package it.palsoftware.pastiera + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.CloudDownload +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import it.palsoftware.pastiera.inputmethod.whisper.WhisperModel +import it.palsoftware.pastiera.inputmethod.whisper.WhisperModelDownloader +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WhisperSettingsScreen( + modifier: Modifier = Modifier, + onBack: () -> Unit +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var useWhisper by remember { mutableStateOf(SettingsManager.getUseWhisper(context)) } + var selectedModel by remember { mutableStateOf(SettingsManager.getWhisperModel(context)) } + var isDownloading by remember { mutableStateOf(false) } + var downloadProgress by remember { mutableStateOf(0f) } + var downloadedModels by remember { mutableStateOf(setOf()) } + + val downloader = remember { WhisperModelDownloader(context) } + + // Check which models are downloaded + LaunchedEffect(Unit) { + downloadedModels = WhisperModel.values().filter { downloader.isModelDownloaded(it) }.toSet() + } + + Scaffold( + topBar = { + Surface( + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.statusBars), + tonalElevation = 1.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.settings_back_content_description) + ) + } + Text( + text = stringResource(R.string.settings_category_speech_recognition), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(start = 8.dp) + ) + } + } + } + ) { paddingValues -> + Column( + modifier = modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + ) { + // Use Whisper Toggle + Surface( + modifier = Modifier + .fillMaxWidth() + .height(72.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.whisper_enabled_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + Text( + text = stringResource(R.string.whisper_enabled_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = useWhisper, + onCheckedChange = { + useWhisper = it + SettingsManager.setUseWhisper(context, it) + }, + enabled = downloadedModels.contains(selectedModel) + ) + } + } + + HorizontalDivider() + + // Model Selection + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Column { + Text( + text = stringResource(R.string.whisper_model_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + Text( + text = stringResource(R.string.whisper_model_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp, bottom = 16.dp) + ) + + // Model Cards + WhisperModel.values().forEach { model -> + val isDownloaded = downloadedModels.contains(model) + val isSelected = selectedModel == model + + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + onClick = { + if (isDownloaded) { + selectedModel = model + SettingsManager.setWhisperModel(context, model) + } + } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = isSelected, + onClick = { + if (isDownloaded) { + selectedModel = model + SettingsManager.setWhisperModel(context, model) + } + }, + enabled = isDownloaded + ) + + Column( + modifier = Modifier + .weight(1f) + .padding(start = 12.dp) + ) { + Text( + text = model.displayName, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Medium + ) + Text( + text = model.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "${model.sizeBytes / (1024 * 1024)} MB", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 4.dp) + ) + if (isDownloaded) { + Text( + text = stringResource(R.string.whisper_model_status_downloaded), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.tertiary + ) + } + } + + // Download/Delete Button + if (isDownloaded) { + IconButton( + onClick = { + downloader.deleteModel(model) + downloadedModels = downloadedModels - model + if (selectedModel == model) { + useWhisper = false + SettingsManager.setUseWhisper(context, false) + } + } + ) { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = "Delete", + tint = MaterialTheme.colorScheme.error + ) + } + } else { + IconButton( + onClick = { + if (!isDownloading) { + isDownloading = true + scope.launch { + downloader.downloadModel( + model, + object : WhisperModelDownloader.DownloadListener { + override fun onProgress(bytesDownloaded: Long, totalBytes: Long) { + downloadProgress = bytesDownloaded.toFloat() / totalBytes + } + override fun onComplete(file: java.io.File) { + isDownloading = false + downloadedModels = downloadedModels + model + } + override fun onError(error: String) { + isDownloading = false + } + } + ) + } + } + }, + enabled = !isDownloading + ) { + Icon( + imageVector = Icons.Filled.CloudDownload, + contentDescription = "Download" + ) + } + } + } + } + } + + // Download Progress + if (isDownloading) { + LinearProgressIndicator( + progress = { downloadProgress }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp) + ) + } + } + } + } + } +} + From 439e8f629e38c7de2397199c4cb7ab65d9b1d150 Mon Sep 17 00:00:00 2001 From: Patrick Zauner <74716024+pzauner@users.noreply.github.com> Date: Sun, 4 Jan 2026 02:49:24 +0100 Subject: [PATCH 06/14] Add Whisper Settings UI with model download/management - Implement WhisperSettingsScreen with model selection - Add model download progress tracking - Implement delete model functionality - Add toggle between Google and Whisper recognition - All strings and translations already in place - UI shows download status, model size, and description --- app/build.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.properties b/app/build.properties index 06cff8f5..b6c0ba4e 100644 --- a/app/build.properties +++ b/app/build.properties @@ -1,4 +1,4 @@ #Build number and date -#Sun Jan 04 02:46:20 CET 2026 +#Sun Jan 04 02:49:03 CET 2026 buildDate=04 gen 2026 -buildNumber=1835 +buildNumber=1836 From 26e90db7755b16c996048909b8deb67c23aec92d Mon Sep 17 00:00:00 2001 From: Patrick Zauner <74716024+pzauner@users.noreply.github.com> Date: Sun, 4 Jan 2026 02:51:17 +0100 Subject: [PATCH 07/14] Integrate Whisper into IME speech recognition - Add WhisperRecognitionManager variable to PhysicalKeyboardInputMethodService - Implement toggle logic between Google and Whisper based on settings - Update startSpeechRecognition to check useWhisper setting - Update stopSpeechRecognition to stop both managers - Add error toast for Whisper recognition failures - Maintain consistent UI callbacks for both recognition modes --- app/build.properties | 4 +- .../PhysicalKeyboardInputMethodService.kt | 121 ++++++++++++------ 2 files changed, 85 insertions(+), 40 deletions(-) diff --git a/app/build.properties b/app/build.properties index b6c0ba4e..04e23ae0 100644 --- a/app/build.properties +++ b/app/build.properties @@ -1,4 +1,4 @@ #Build number and date -#Sun Jan 04 02:49:03 CET 2026 +#Sun Jan 04 02:50:59 CET 2026 buildDate=04 gen 2026 -buildNumber=1836 +buildNumber=1838 diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/PhysicalKeyboardInputMethodService.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/PhysicalKeyboardInputMethodService.kt index bb122c24..8c536e6a 100644 --- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/PhysicalKeyboardInputMethodService.kt +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/PhysicalKeyboardInputMethodService.kt @@ -70,6 +70,7 @@ class PhysicalKeyboardInputMethodService : InputMethodService() { // Speech recognition using SpeechRecognizer (modern approach) private var speechRecognitionManager: SpeechRecognitionManager? = null + private var whisperRecognitionManager: it.palsoftware.pastiera.inputmethod.whisper.WhisperRecognitionManager? = null private var isSpeechRecognitionActive: Boolean = false private var pendingSpeechRecognition: Boolean = false @@ -273,48 +274,91 @@ class PhysicalKeyboardInputMethodService : InputMethodService() { return } - // Initialize manager if not already created - if (speechRecognitionManager == null) { - speechRecognitionManager = SpeechRecognitionManager( - context = this, - inputConnectionProvider = { currentInputConnection }, - onError = { errorMessage -> - Log.e(TAG, "Speech recognition error: $errorMessage") - }, - onRecognitionStateChanged = { isActive -> - // Update internal state - isSpeechRecognitionActive = isActive - - // Reset Alt and Ctrl modifiers when recognition starts - if (isActive) { - modifierStateController.clearAltState() - modifierStateController.clearCtrlState() - } - - // Update microphone button color and hint message based on recognition state - uiHandler.post { - candidatesBarController.setMicrophoneButtonActive(isActive) - candidatesBarController.showSpeechRecognitionHint(isActive) - // Reset audio level when recognition stops - if (!isActive) { - candidatesBarController.updateMicrophoneAudioLevel(-10f) - } else { - // Update status bar after resetting modifiers - updateStatusBarText() + // Check if Whisper is enabled + val useWhisper = it.palsoftware.pastiera.SettingsManager.getUseWhisper(this) + + if (useWhisper) { + // Use Whisper for offline recognition + if (whisperRecognitionManager == null) { + whisperRecognitionManager = it.palsoftware.pastiera.inputmethod.whisper.WhisperRecognitionManager( + context = this, + inputConnectionProvider = { currentInputConnection }, + onError = { errorMessage -> + Log.e(TAG, "Whisper recognition error: $errorMessage") + uiHandler.post { + Toast.makeText(this, errorMessage, Toast.LENGTH_SHORT).show() + } + }, + onRecognitionStateChanged = { isActive -> + isSpeechRecognitionActive = isActive + + if (isActive) { + modifierStateController.clearAltState() + modifierStateController.clearCtrlState() + } + + uiHandler.post { + candidatesBarController.setMicrophoneButtonActive(isActive) + candidatesBarController.showSpeechRecognitionHint(isActive) + if (!isActive) { + candidatesBarController.updateMicrophoneAudioLevel(-10f) + } else { + updateStatusBarText() + } + } + }, + shouldDisableAutoCapitalize = { inputContextState.shouldDisableAutoCapitalize }, + onAudioLevelChanged = { rmsdB -> + uiHandler.post { + candidatesBarController.updateMicrophoneAudioLevel(rmsdB) } } - }, - shouldDisableAutoCapitalize = { inputContextState.shouldDisableAutoCapitalize }, - onAudioLevelChanged = { rmsdB -> - // Update microphone button based on audio level - uiHandler.post { - candidatesBarController.updateMicrophoneAudioLevel(rmsdB) + ) + } + whisperRecognitionManager?.startRecognition() + } else { + // Use Google SpeechRecognizer (existing implementation) + if (speechRecognitionManager == null) { + speechRecognitionManager = SpeechRecognitionManager( + context = this, + inputConnectionProvider = { currentInputConnection }, + onError = { errorMessage -> + Log.e(TAG, "Speech recognition error: $errorMessage") + }, + onRecognitionStateChanged = { isActive -> + // Update internal state + isSpeechRecognitionActive = isActive + + // Reset Alt and Ctrl modifiers when recognition starts + if (isActive) { + modifierStateController.clearAltState() + modifierStateController.clearCtrlState() + } + + // Update microphone button color and hint message based on recognition state + uiHandler.post { + candidatesBarController.setMicrophoneButtonActive(isActive) + candidatesBarController.showSpeechRecognitionHint(isActive) + // Reset audio level when recognition stops + if (!isActive) { + candidatesBarController.updateMicrophoneAudioLevel(-10f) + } else { + // Update status bar after resetting modifiers + updateStatusBarText() + } + } + }, + shouldDisableAutoCapitalize = { inputContextState.shouldDisableAutoCapitalize }, + onAudioLevelChanged = { rmsdB -> + // Update microphone button based on audio level + uiHandler.post { + candidatesBarController.updateMicrophoneAudioLevel(rmsdB) + } } - } - ) + ) + } + speechRecognitionManager?.startRecognition() } - - speechRecognitionManager?.startRecognition() } /** @@ -322,6 +366,7 @@ class PhysicalKeyboardInputMethodService : InputMethodService() { */ private fun stopSpeechRecognition() { speechRecognitionManager?.stopRecognition() + whisperRecognitionManager?.stopRecognition() } private fun getSuggestionSettings(): SuggestionSettings { From 1224fac387c4dca0df58ae6ee26d68a674c4b884 Mon Sep 17 00:00:00 2001 From: Patrick Zauner <74716024+pzauner@users.noreply.github.com> Date: Sun, 4 Jan 2026 02:54:20 +0100 Subject: [PATCH 08/14] Fix: Add comprehensive logging and error handling to Whisper model download - Add detailed Log.d statements for download progress - Add Toast notifications for success/error - Catch and display exceptions in download coroutine - Log Result status from downloadModel - Improve user feedback for download issues --- .kotlin/errors/errors-1767491578848.log | 73 ++++++++++++++++++ .kotlin/errors/errors-1767491579232.log | 77 +++++++++++++++++++ app/build.properties | 4 +- .../pastiera/WhisperSettingsScreen.kt | 53 +++++++++---- 4 files changed, 191 insertions(+), 16 deletions(-) create mode 100644 .kotlin/errors/errors-1767491578848.log create mode 100644 .kotlin/errors/errors-1767491579232.log diff --git a/.kotlin/errors/errors-1767491578848.log b/.kotlin/errors/errors-1767491578848.log new file mode 100644 index 00000000..94842d4e --- /dev/null +++ b/.kotlin/errors/errors-1767491578848.log @@ -0,0 +1,73 @@ +kotlin version: 2.0.21 +error message: Daemon compilation failed: null +java.lang.Exception + at org.jetbrains.kotlin.daemon.common.CompileService$CallResult$Error.get(CompileService.kt:69) + at org.jetbrains.kotlin.daemon.common.CompileService$CallResult$Error.get(CompileService.kt:65) + at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemon(GradleKotlinCompilerWork.kt:240) + at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemonOrFallbackImpl(GradleKotlinCompilerWork.kt:159) + at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.run(GradleKotlinCompilerWork.kt:111) + at org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction.execute(GradleCompilerRunnerWithWorkers.kt:76) + at org.gradle.workers.internal.DefaultWorkerServer.execute(DefaultWorkerServer.java:63) + at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:66) + at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:62) + at org.gradle.internal.classloader.ClassLoaderUtils.executeInClassloader(ClassLoaderUtils.java:100) + at org.gradle.workers.internal.NoIsolationWorkerFactory$1.lambda$execute$0(NoIsolationWorkerFactory.java:62) + at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:44) + at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:41) + at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:210) + at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:205) + at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:67) + at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:60) + at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:167) + at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:60) + at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:54) + at org.gradle.workers.internal.AbstractWorker.executeWrappedInBuildOperation(AbstractWorker.java:41) + at org.gradle.workers.internal.NoIsolationWorkerFactory$1.execute(NoIsolationWorkerFactory.java:59) + at org.gradle.workers.internal.DefaultWorkerExecutor.lambda$submitWork$0(DefaultWorkerExecutor.java:174) + at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317) + at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runExecution(DefaultConditionalExecutionQueue.java:194) + at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.access$700(DefaultConditionalExecutionQueue.java:127) + at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner$1.run(DefaultConditionalExecutionQueue.java:169) + at org.gradle.internal.Factories$1.create(Factories.java:31) + at org.gradle.internal.work.DefaultWorkerLeaseService.withLocks(DefaultWorkerLeaseService.java:263) + at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:127) + at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:132) + at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runBatch(DefaultConditionalExecutionQueue.java:164) + at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.run(DefaultConditionalExecutionQueue.java:133) + at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:572) + at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317) + at org.gradle.internal.concurrent.ExecutorPolicy$CatchAndRecordFailures.onExecute(ExecutorPolicy.java:64) + at org.gradle.internal.concurrent.AbstractManagedExecutor$1.run(AbstractManagedExecutor.java:48) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642) + at java.base/java.lang.Thread.run(Thread.java:1583) +Caused by: java.nio.file.NoSuchFileException: /var/folders/4_/b5c0q1mn37nbw64p1jpjyd440000gn/T/kotlin-backups1738321951739408459/457.backup -> /Users/user/gits/GitHub/pastiera/app/build/tmp/kotlin-classes/debug/it/palsoftware/pastiera/VariationPickerDialogKt$VariationPickerDialog$1$1.class + at java.base/sun.nio.fs.UnixException.translateToIOException(Unknown Source) + at java.base/sun.nio.fs.UnixException.rethrowAsIOException(Unknown Source) + at java.base/sun.nio.fs.UnixFileSystem.move(Unknown Source) + at java.base/sun.nio.fs.UnixFileSystemProvider.move(Unknown Source) + at java.base/java.nio.file.Files.move(Unknown Source) + at org.jetbrains.kotlin.incremental.RecoverableCompilationTransaction.revertChanges(CompilationTransaction.kt:231) + at org.jetbrains.kotlin.incremental.RecoverableCompilationTransaction.close(CompilationTransaction.kt:256) + at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.tryCompileIncrementally(IncrementalCompilerRunner.kt:747) + at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compile(IncrementalCompilerRunner.kt:120) + at org.jetbrains.kotlin.daemon.CompileServiceImplBase.execIncrementalCompiler(CompileServiceImpl.kt:675) + at org.jetbrains.kotlin.daemon.CompileServiceImplBase.access$execIncrementalCompiler(CompileServiceImpl.kt:92) + at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1660) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source) + at java.base/java.lang.reflect.Method.invoke(Unknown Source) + at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(Unknown Source) + at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source) + at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source) + at java.base/java.security.AccessController.doPrivileged(Unknown Source) + at java.rmi/sun.rmi.transport.Transport.serviceCall(Unknown Source) + at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(Unknown Source) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(Unknown Source) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(Unknown Source) + at java.base/java.security.AccessController.doPrivileged(Unknown Source) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source) + at java.base/java.lang.Thread.run(Unknown Source) + + diff --git a/.kotlin/errors/errors-1767491579232.log b/.kotlin/errors/errors-1767491579232.log new file mode 100644 index 00000000..d8ce274a --- /dev/null +++ b/.kotlin/errors/errors-1767491579232.log @@ -0,0 +1,77 @@ +kotlin version: 2.0.21 +error message: Incremental compilation failed: Storage for [/Users/user/gits/GitHub/pastiera/app/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab] is already registered +java.lang.IllegalStateException: Storage for [/Users/user/gits/GitHub/pastiera/app/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab] is already registered + at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:410) + at org.jetbrains.kotlin.com.intellij.util.io.PagedFileStorage.(PagedFileStorage.java:72) + at org.jetbrains.kotlin.com.intellij.util.io.ResizeableMappedFile.(ResizeableMappedFile.java:55) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentBTreeEnumerator.(PersistentBTreeEnumerator.java:128) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentEnumerator.createDefaultEnumerator(PersistentEnumerator.java:52) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.(PersistentMapImpl.java:165) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.(PersistentMapImpl.java:140) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.buildImplementation(PersistentMapBuilder.java:88) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.build(PersistentMapBuilder.java:71) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.(PersistentHashMap.java:45) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.(PersistentHashMap.java:71) + at org.jetbrains.kotlin.incremental.storage.LazyStorage.createMap(LazyStorage.kt:62) + at org.jetbrains.kotlin.incremental.storage.LazyStorage.getStorageIfExists(LazyStorage.kt:53) + at org.jetbrains.kotlin.incremental.storage.LazyStorage.get(LazyStorage.kt:76) + at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.get(InMemoryStorage.kt:68) + at org.jetbrains.kotlin.incremental.storage.AppendableInMemoryStorage.get(InMemoryStorage.kt:155) + at org.jetbrains.kotlin.incremental.storage.AppendableInMemoryStorage.get(InMemoryStorage.kt:147) + at org.jetbrains.kotlin.incremental.storage.AppendableSetBasicMap.get(BasicMap.kt:137) + at org.jetbrains.kotlin.incremental.TrackedLookupMap.get(LookupStorage.kt:308) + at org.jetbrains.kotlin.incremental.LookupStorage.get(LookupStorage.kt:94) + at org.jetbrains.kotlin.incremental.BuildUtilKt.mapLookupSymbolsToFiles(buildUtil.kt:221) + at org.jetbrains.kotlin.incremental.BuildUtilKt.mapLookupSymbolsToFiles$default(buildUtil.kt:212) + at org.jetbrains.kotlin.incremental.DirtyFilesContainer.addByDirtySymbols(DirtyFilesContainer.kt:37) + at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.calculateSourcesToCompileImpl(IncrementalJvmCompilerRunner.kt:263) + at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.calculateSourcesToCompile(IncrementalJvmCompilerRunner.kt:143) + at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.calculateSourcesToCompile(IncrementalJvmCompilerRunner.kt:73) + at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.tryCompileIncrementally$lambda$9$compile(IncrementalCompilerRunner.kt:225) + at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.tryCompileIncrementally(IncrementalCompilerRunner.kt:267) + at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compile(IncrementalCompilerRunner.kt:120) + at org.jetbrains.kotlin.daemon.CompileServiceImplBase.execIncrementalCompiler(CompileServiceImpl.kt:675) + at org.jetbrains.kotlin.daemon.CompileServiceImplBase.access$execIncrementalCompiler(CompileServiceImpl.kt:92) + at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1660) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source) + at java.base/java.lang.reflect.Method.invoke(Unknown Source) + at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(Unknown Source) + at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source) + at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source) + at java.base/java.security.AccessController.doPrivileged(Unknown Source) + at java.rmi/sun.rmi.transport.Transport.serviceCall(Unknown Source) + at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(Unknown Source) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(Unknown Source) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(Unknown Source) + at java.base/java.security.AccessController.doPrivileged(Unknown Source) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source) + at java.base/java.lang.Thread.run(Unknown Source) + Suppressed: java.lang.Exception: Storage[/Users/user/gits/GitHub/pastiera/app/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab] registration stack trace + at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:437) + at org.jetbrains.kotlin.com.intellij.util.io.PagedFileStorage.(PagedFileStorage.java:72) + at org.jetbrains.kotlin.com.intellij.util.io.ResizeableMappedFile.(ResizeableMappedFile.java:55) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentBTreeEnumerator.(PersistentBTreeEnumerator.java:128) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentEnumerator.createDefaultEnumerator(PersistentEnumerator.java:52) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.(PersistentMapImpl.java:165) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.(PersistentMapImpl.java:140) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.buildImplementation(PersistentMapBuilder.java:88) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.build(PersistentMapBuilder.java:71) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.(PersistentHashMap.java:45) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.(PersistentHashMap.java:71) + at org.jetbrains.kotlin.incremental.storage.LazyStorage.createMap(LazyStorage.kt:62) + at org.jetbrains.kotlin.incremental.storage.LazyStorage.getStorageIfExists(LazyStorage.kt:53) + at org.jetbrains.kotlin.incremental.storage.LazyStorage.getKeys(LazyStorage.kt:67) + at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.getKeys(InMemoryStorage.kt:50) + at org.jetbrains.kotlin.incremental.storage.AppendableSetBasicMap.getKeys(BasicMap.kt:126) + at org.jetbrains.kotlin.incremental.TrackedLookupMap.getKeys(LookupStorage.kt:306) + at org.jetbrains.kotlin.incremental.LookupStorage.getLookupSymbols(LookupStorage.kt:89) + at org.jetbrains.kotlin.incremental.classpathDiff.ClasspathSnapshotShrinker.shrinkClasspath$lambda$0(ClasspathSnapshotShrinker.kt:37) + at org.jetbrains.kotlin.incremental.classpathDiff.ClasspathSnapshotShrinker$MetricsReporter.getLookupSymbols(ClasspathSnapshotShrinker.kt:330) + at org.jetbrains.kotlin.incremental.classpathDiff.ClasspathSnapshotShrinker.shrinkClasspath(ClasspathSnapshotShrinker.kt:36) + at org.jetbrains.kotlin.incremental.classpathDiff.ClasspathChangesComputer.computeClasspathChanges(ClasspathChangesComputer.kt:50) + at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.calculateSourcesToCompileImpl(IncrementalJvmCompilerRunner.kt:205) + ... 23 more + + diff --git a/app/build.properties b/app/build.properties index 04e23ae0..c8cfb7d7 100644 --- a/app/build.properties +++ b/app/build.properties @@ -1,4 +1,4 @@ #Build number and date -#Sun Jan 04 02:50:59 CET 2026 +#Sun Jan 04 02:52:59 CET 2026 buildDate=04 gen 2026 -buildNumber=1838 +buildNumber=1841 diff --git a/app/src/main/java/it/palsoftware/pastiera/WhisperSettingsScreen.kt b/app/src/main/java/it/palsoftware/pastiera/WhisperSettingsScreen.kt index b44679bf..3b4a3630 100644 --- a/app/src/main/java/it/palsoftware/pastiera/WhisperSettingsScreen.kt +++ b/app/src/main/java/it/palsoftware/pastiera/WhisperSettingsScreen.kt @@ -221,21 +221,46 @@ fun WhisperSettingsScreen( if (!isDownloading) { isDownloading = true scope.launch { - downloader.downloadModel( - model, - object : WhisperModelDownloader.DownloadListener { - override fun onProgress(bytesDownloaded: Long, totalBytes: Long) { - downloadProgress = bytesDownloaded.toFloat() / totalBytes + try { + android.util.Log.d("WhisperSettings", "Starting download for ${model.displayName}") + val result = downloader.downloadModel( + model, + object : WhisperModelDownloader.DownloadListener { + override fun onProgress(bytesDownloaded: Long, totalBytes: Long) { + downloadProgress = bytesDownloaded.toFloat() / totalBytes + android.util.Log.d("WhisperSettings", "Progress: $bytesDownloaded / $totalBytes") + } + override fun onComplete(file: java.io.File) { + android.util.Log.d("WhisperSettings", "Download complete: ${file.absolutePath}") + isDownloading = false + downloadedModels = downloadedModels + model + android.widget.Toast.makeText( + context, + context.getString(R.string.whisper_model_download_complete), + android.widget.Toast.LENGTH_SHORT + ).show() + } + override fun onError(error: String) { + android.util.Log.e("WhisperSettings", "Download error: $error") + isDownloading = false + android.widget.Toast.makeText( + context, + context.getString(R.string.whisper_model_download_failed) + ": $error", + android.widget.Toast.LENGTH_LONG + ).show() + } } - override fun onComplete(file: java.io.File) { - isDownloading = false - downloadedModels = downloadedModels + model - } - override fun onError(error: String) { - isDownloading = false - } - } - ) + ) + android.util.Log.d("WhisperSettings", "Download result: ${result.isSuccess}") + } catch (e: Exception) { + android.util.Log.e("WhisperSettings", "Exception during download", e) + isDownloading = false + android.widget.Toast.makeText( + context, + "Error: ${e.message}", + android.widget.Toast.LENGTH_LONG + ).show() + } } } }, From 1f4d3b3de2554c787ce191c34018adf4d296f395 Mon Sep 17 00:00:00 2001 From: Patrick Zauner <74716024+pzauner@users.noreply.github.com> Date: Sun, 4 Jan 2026 03:00:09 +0100 Subject: [PATCH 09/14] Fix: Correct Whisper model filenames for Hugging Face - Change from whisper_tiny_en.tflite to whisper-tiny-en.tflite - Change from whisper_base.tflite to whisper-base.tflite - Change from whisper_small.tflite to whisper-small.tflite - Use correct filenames from DocWolle/whisper_tflite_models repo - Add comprehensive download logging and error messages - Fix Toast threading issues (moved to Main thread) - Add progress indicator in download button Fixes 404 errors when downloading models from Hugging Face --- app/build.properties | 4 +- .../pastiera/WhisperSettingsScreen.kt | 79 ++++++++----- .../inputmethod/whisper/WhisperModel.kt | 6 +- .../whisper/WhisperModelDownloader.kt | 108 ++++++++++++------ 4 files changed, 129 insertions(+), 68 deletions(-) diff --git a/app/build.properties b/app/build.properties index c8cfb7d7..547875c7 100644 --- a/app/build.properties +++ b/app/build.properties @@ -1,4 +1,4 @@ #Build number and date -#Sun Jan 04 02:52:59 CET 2026 +#Sun Jan 04 02:59:23 CET 2026 buildDate=04 gen 2026 -buildNumber=1841 +buildNumber=1845 diff --git a/app/src/main/java/it/palsoftware/pastiera/WhisperSettingsScreen.kt b/app/src/main/java/it/palsoftware/pastiera/WhisperSettingsScreen.kt index 3b4a3630..0be80814 100644 --- a/app/src/main/java/it/palsoftware/pastiera/WhisperSettingsScreen.kt +++ b/app/src/main/java/it/palsoftware/pastiera/WhisperSettingsScreen.kt @@ -220,37 +220,47 @@ fun WhisperSettingsScreen( onClick = { if (!isDownloading) { isDownloading = true + downloadProgress = 0f scope.launch { try { android.util.Log.d("WhisperSettings", "Starting download for ${model.displayName}") - val result = downloader.downloadModel( - model, - object : WhisperModelDownloader.DownloadListener { - override fun onProgress(bytesDownloaded: Long, totalBytes: Long) { - downloadProgress = bytesDownloaded.toFloat() / totalBytes - android.util.Log.d("WhisperSettings", "Progress: $bytesDownloaded / $totalBytes") + + val result = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + downloader.downloadModel( + model, + object : WhisperModelDownloader.DownloadListener { + override fun onProgress(bytesDownloaded: Long, totalBytes: Long) { + downloadProgress = bytesDownloaded.toFloat() / totalBytes + android.util.Log.d("WhisperSettings", "Progress: $bytesDownloaded / $totalBytes (${(downloadProgress * 100).toInt()}%)") + } + override fun onComplete(file: java.io.File) { + android.util.Log.d("WhisperSettings", "Download complete: ${file.absolutePath}") + } + override fun onError(error: String) { + android.util.Log.e("WhisperSettings", "Download error: $error") + } } - override fun onComplete(file: java.io.File) { - android.util.Log.d("WhisperSettings", "Download complete: ${file.absolutePath}") - isDownloading = false - downloadedModels = downloadedModels + model - android.widget.Toast.makeText( - context, - context.getString(R.string.whisper_model_download_complete), - android.widget.Toast.LENGTH_SHORT - ).show() - } - override fun onError(error: String) { - android.util.Log.e("WhisperSettings", "Download error: $error") - isDownloading = false - android.widget.Toast.makeText( - context, - context.getString(R.string.whisper_model_download_failed) + ": $error", - android.widget.Toast.LENGTH_LONG - ).show() - } - } - ) + ) + } + + // Back on Main thread for UI updates + isDownloading = false + if (result.isSuccess) { + downloadedModels = downloadedModels + model + android.widget.Toast.makeText( + context, + context.getString(R.string.whisper_model_download_complete), + android.widget.Toast.LENGTH_SHORT + ).show() + } else { + val errorMsg = result.exceptionOrNull()?.message ?: "Unknown error" + android.util.Log.e("WhisperSettings", "Download failed: $errorMsg") + android.widget.Toast.makeText( + context, + "${context.getString(R.string.whisper_model_download_failed)}: $errorMsg", + android.widget.Toast.LENGTH_LONG + ).show() + } android.util.Log.d("WhisperSettings", "Download result: ${result.isSuccess}") } catch (e: Exception) { android.util.Log.e("WhisperSettings", "Exception during download", e) @@ -266,10 +276,17 @@ fun WhisperSettingsScreen( }, enabled = !isDownloading ) { - Icon( - imageVector = Icons.Filled.CloudDownload, - contentDescription = "Download" - ) + if (isDownloading) { + androidx.compose.material3.CircularProgressIndicator( + modifier = androidx.compose.ui.Modifier.size(24.dp), + strokeWidth = 2.dp + ) + } else { + Icon( + imageVector = Icons.Filled.CloudDownload, + contentDescription = "Download" + ) + } } } } diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperModel.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperModel.kt index b4ae4a67..ae4d0f87 100644 --- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperModel.kt +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperModel.kt @@ -12,21 +12,21 @@ enum class WhisperModel( ) { TINY_EN( displayName = "Tiny (English only)", - fileName = "whisper_tiny_en.tflite", + fileName = "whisper-tiny-en.tflite", sizeBytes = 75 * 1024 * 1024, // ~75 MB isMultilingual = false, description = "Fast, English only, good for clear speech" ), BASE( displayName = "Base (Multilingual)", - fileName = "whisper_base.tflite", + fileName = "whisper-base.tflite", sizeBytes = 150 * 1024 * 1024, // ~150 MB isMultilingual = true, description = "Balanced quality and speed, supports 99 languages" ), SMALL( displayName = "Small (Multilingual)", - fileName = "whisper_small.tflite", + fileName = "whisper-small.tflite", sizeBytes = 500 * 1024 * 1024, // ~500 MB isMultilingual = true, description = "Excellent quality, may be slower on some devices" diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperModelDownloader.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperModelDownloader.kt index ba0ecf3d..b85bb477 100644 --- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperModelDownloader.kt +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperModelDownloader.kt @@ -123,49 +123,93 @@ class WhisperModelDownloader(private val context: Context) { targetFile: File, listener: DownloadListener? ) { + Log.d(TAG, "Attempting to download from: $url") + Log.d(TAG, "Target file: ${targetFile.absolutePath}") + val request = Request.Builder() .url(url) .build() - client.newCall(request).execute().use { response -> - if (!response.isSuccessful) { - throw Exception("Download failed: HTTP ${response.code}") - } + try { + client.newCall(request).execute().use { response -> + Log.d(TAG, "Response code: ${response.code}") + + if (!response.isSuccessful) { + val errorMsg = "Download failed: HTTP ${response.code} - ${response.message}" + Log.e(TAG, errorMsg) + throw Exception(errorMsg) + } - val body = response.body ?: throw Exception("Empty response body") - val contentLength = body.contentLength() + val body = response.body + if (body == null) { + val errorMsg = "Empty response body from server" + Log.e(TAG, errorMsg) + throw Exception(errorMsg) + } + + val contentLength = body.contentLength() + Log.d(TAG, "Content length: $contentLength bytes (${contentLength / (1024 * 1024)} MB)") - // Create temp file for atomic write - val tempFile = File(targetFile.parentFile, "${targetFile.name}.tmp") - - FileOutputStream(tempFile).use { output -> - val buffer = ByteArray(8192) - var bytesRead: Int - var totalBytesRead = 0L - - body.byteStream().use { input -> - while (input.read(buffer).also { bytesRead = it } != -1) { - output.write(buffer, 0, bytesRead) - totalBytesRead += bytesRead - - listener?.onProgress(totalBytesRead, contentLength) + // Create temp file for atomic write + val tempFile = File(targetFile.parentFile, "${targetFile.name}.tmp") + Log.d(TAG, "Writing to temp file: ${tempFile.absolutePath}") + + FileOutputStream(tempFile).use { output -> + val buffer = ByteArray(8192) + var bytesRead: Int + var totalBytesRead = 0L + + body.byteStream().use { input -> + while (input.read(buffer).also { bytesRead = it } != -1) { + output.write(buffer, 0, bytesRead) + totalBytesRead += bytesRead + + listener?.onProgress(totalBytesRead, contentLength) + + // Log progress every 10 MB + if (totalBytesRead % (10 * 1024 * 1024) == 0L) { + Log.d(TAG, "Downloaded: ${totalBytesRead / (1024 * 1024)} MB") + } + } } + Log.d(TAG, "Download complete: $totalBytesRead bytes written") } - } - // Verify download - if (contentLength > 0 && tempFile.length() != contentLength) { - tempFile.delete() - throw Exception("Download incomplete: ${tempFile.length()} of $contentLength bytes") - } + // Verify download + if (contentLength > 0 && tempFile.length() != contentLength) { + tempFile.delete() + val errorMsg = "Download incomplete: ${tempFile.length()} of $contentLength bytes" + Log.e(TAG, errorMsg) + throw Exception(errorMsg) + } - // Move temp file to target - if (targetFile.exists()) { - targetFile.delete() - } - tempFile.renameTo(targetFile) + // Move temp file to target + if (targetFile.exists()) { + Log.d(TAG, "Deleting existing file: ${targetFile.absolutePath}") + targetFile.delete() + } + val renamed = tempFile.renameTo(targetFile) + if (!renamed) { + val errorMsg = "Failed to rename temp file to target file" + Log.e(TAG, errorMsg) + throw Exception(errorMsg) + } + Log.d(TAG, "File successfully saved to: ${targetFile.absolutePath}") - listener?.onComplete(targetFile) + listener?.onComplete(targetFile) + } + } catch (e: java.net.UnknownHostException) { + val errorMsg = "Network error: Cannot resolve host. Check internet connection." + Log.e(TAG, errorMsg, e) + throw Exception(errorMsg, e) + } catch (e: java.net.SocketTimeoutException) { + val errorMsg = "Network timeout. Check internet connection." + Log.e(TAG, errorMsg, e) + throw Exception(errorMsg, e) + } catch (e: java.io.IOException) { + val errorMsg = "Network I/O error: ${e.message}" + Log.e(TAG, errorMsg, e) + throw Exception(errorMsg, e) } } From d5f63e2eaff0703cbb9fc13552d5843b69d051e7 Mon Sep 17 00:00:00 2001 From: Patrick Zauner <74716024+pzauner@users.noreply.github.com> Date: Sun, 4 Jan 2026 03:02:31 +0100 Subject: [PATCH 10/14] Fix: Use correct Whisper model filenames from Hugging Face CRITICAL FIX - Previous filenames were completely wrong! Correct filenames from DocWolle/whisper_tflite_models: - whisper-tiny.en.tflite (with DOT between tiny and en!) - whisper-base.TOP_WORLD.tflite (not just whisper-base.tflite!) - whisper-small.tflite (this one was correct) Also updated file sizes to match actual models: - Tiny: 42 MB (was 75 MB) - Base TOP_WORLD: 108 MB (was 150 MB) - Small: 388 MB (was 500 MB) This should fix the 404 errors when downloading models. --- app/build.properties | 4 ++-- .../pastiera/inputmethod/whisper/WhisperModel.kt | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/build.properties b/app/build.properties index 547875c7..aa819916 100644 --- a/app/build.properties +++ b/app/build.properties @@ -1,4 +1,4 @@ #Build number and date -#Sun Jan 04 02:59:23 CET 2026 +#Sun Jan 04 03:01:55 CET 2026 buildDate=04 gen 2026 -buildNumber=1845 +buildNumber=1847 diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperModel.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperModel.kt index ae4d0f87..d29cd129 100644 --- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperModel.kt +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperModel.kt @@ -12,22 +12,22 @@ enum class WhisperModel( ) { TINY_EN( displayName = "Tiny (English only)", - fileName = "whisper-tiny-en.tflite", - sizeBytes = 75 * 1024 * 1024, // ~75 MB + fileName = "whisper-tiny.en.tflite", + sizeBytes = 42 * 1024 * 1024, // ~42 MB isMultilingual = false, description = "Fast, English only, good for clear speech" ), BASE( displayName = "Base (Multilingual)", - fileName = "whisper-base.tflite", - sizeBytes = 150 * 1024 * 1024, // ~150 MB + fileName = "whisper-base.TOP_WORLD.tflite", + sizeBytes = 108 * 1024 * 1024, // ~108 MB isMultilingual = true, - description = "Balanced quality and speed, supports 99 languages" + description = "Balanced quality and speed, supports top world languages" ), SMALL( displayName = "Small (Multilingual)", fileName = "whisper-small.tflite", - sizeBytes = 500 * 1024 * 1024, // ~500 MB + sizeBytes = 388 * 1024 * 1024, // ~388 MB isMultilingual = true, description = "Excellent quality, may be slower on some devices" ); From cb7ff885e8433031b9e8b1e787cfd8807e4fc425 Mon Sep 17 00:00:00 2001 From: Patrick Zauner <74716024+pzauner@users.noreply.github.com> Date: Sun, 4 Jan 2026 03:08:26 +0100 Subject: [PATCH 11/14] Fix: Improve download UI - per-model progress and better UX - Change from global isDownloading to per-model downloadingModel - Show spinner only on the model being downloaded - Show progress bar inline in model card (always visible during download) - Display download percentage next to progress bar - Remove global progress indicator at bottom - Only disable other download buttons while one is active This fixes the UI issues where all buttons showed spinners and progress was only visible when scrolling. --- app/build.properties | 4 +- .../pastiera/WhisperSettingsScreen.kt | 73 ++++++++++--------- 2 files changed, 40 insertions(+), 37 deletions(-) diff --git a/app/build.properties b/app/build.properties index aa819916..9b51e680 100644 --- a/app/build.properties +++ b/app/build.properties @@ -1,4 +1,4 @@ #Build number and date -#Sun Jan 04 03:01:55 CET 2026 +#Sun Jan 04 03:07:58 CET 2026 buildDate=04 gen 2026 -buildNumber=1847 +buildNumber=1850 diff --git a/app/src/main/java/it/palsoftware/pastiera/WhisperSettingsScreen.kt b/app/src/main/java/it/palsoftware/pastiera/WhisperSettingsScreen.kt index 0be80814..71e3fbc5 100644 --- a/app/src/main/java/it/palsoftware/pastiera/WhisperSettingsScreen.kt +++ b/app/src/main/java/it/palsoftware/pastiera/WhisperSettingsScreen.kt @@ -30,7 +30,7 @@ fun WhisperSettingsScreen( var useWhisper by remember { mutableStateOf(SettingsManager.getUseWhisper(context)) } var selectedModel by remember { mutableStateOf(SettingsManager.getWhisperModel(context)) } - var isDownloading by remember { mutableStateOf(false) } + var downloadingModel by remember { mutableStateOf(null) } var downloadProgress by remember { mutableStateOf(0f) } var downloadedModels by remember { mutableStateOf(setOf()) } @@ -138,6 +138,7 @@ fun WhisperSettingsScreen( WhisperModel.values().forEach { model -> val isDownloaded = downloadedModels.contains(model) val isSelected = selectedModel == model + val isDownloadingThis = downloadingModel == model ElevatedCard( modifier = Modifier @@ -178,24 +179,36 @@ fun WhisperSettingsScreen( fontWeight = FontWeight.Medium ) Text( - text = model.description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = "${model.sizeBytes / (1024 * 1024)} MB", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(top = 4.dp) - ) - if (isDownloaded) { - Text( - text = stringResource(R.string.whisper_model_status_downloaded), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.tertiary - ) - } - } + text = model.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "${model.sizeBytes / (1024 * 1024)} MB", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 4.dp) + ) + if (isDownloaded) { + Text( + text = stringResource(R.string.whisper_model_status_downloaded), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.tertiary + ) + } else if (isDownloadingThis) { + Text( + text = stringResource(R.string.whisper_model_download_in_progress) + " ${(downloadProgress * 100).toInt()}%", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary + ) + LinearProgressIndicator( + progress = { downloadProgress }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp) + ) + } + } // Download/Delete Button if (isDownloaded) { @@ -218,8 +231,8 @@ fun WhisperSettingsScreen( } else { IconButton( onClick = { - if (!isDownloading) { - isDownloading = true + if (downloadingModel == null) { + downloadingModel = model downloadProgress = 0f scope.launch { try { @@ -244,7 +257,7 @@ fun WhisperSettingsScreen( } // Back on Main thread for UI updates - isDownloading = false + downloadingModel = null if (result.isSuccess) { downloadedModels = downloadedModels + model android.widget.Toast.makeText( @@ -264,7 +277,7 @@ fun WhisperSettingsScreen( android.util.Log.d("WhisperSettings", "Download result: ${result.isSuccess}") } catch (e: Exception) { android.util.Log.e("WhisperSettings", "Exception during download", e) - isDownloading = false + downloadingModel = null android.widget.Toast.makeText( context, "Error: ${e.message}", @@ -274,9 +287,9 @@ fun WhisperSettingsScreen( } } }, - enabled = !isDownloading + enabled = downloadingModel == null ) { - if (isDownloading) { + if (isDownloadingThis) { androidx.compose.material3.CircularProgressIndicator( modifier = androidx.compose.ui.Modifier.size(24.dp), strokeWidth = 2.dp @@ -292,16 +305,6 @@ fun WhisperSettingsScreen( } } } - - // Download Progress - if (isDownloading) { - LinearProgressIndicator( - progress = { downloadProgress }, - modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp) - ) - } } } } From ddffdeea0e08e38ddbbdf0233621581c83e39e52 Mon Sep 17 00:00:00 2001 From: Patrick Zauner <74716024+pzauner@users.noreply.github.com> Date: Sun, 4 Jan 2026 03:11:21 +0100 Subject: [PATCH 12/14] Fix: BufferOverflowException in WhisperEngine inference Critical fix for Whisper audio processing: - Fix incorrect buffer size calculation (was dividing by 8 unnecessarily) - Use fold() to calculate total tensor size from shape - Add mel spectrogram size validation and adjustment - Pad or truncate mel spectrogram to match expected tensor size - Add comprehensive logging for tensor shapes and sizes - Handle size mismatches gracefully instead of crashing This fixes the BufferOverflowException at WhisperEngine.kt:150 when trying to process audio for speech recognition. The buffer now correctly allocates expectedInputSize * 4 bytes instead of using the incorrect Float.SIZE_BYTES / 8 formula. --- app/build.properties | 4 +-- .../inputmethod/whisper/WhisperEngine.kt | 30 ++++++++++++++++--- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/app/build.properties b/app/build.properties index 9b51e680..da6ac104 100644 --- a/app/build.properties +++ b/app/build.properties @@ -1,4 +1,4 @@ #Build number and date -#Sun Jan 04 03:07:58 CET 2026 +#Sun Jan 04 03:11:04 CET 2026 buildDate=04 gen 2026 -buildNumber=1850 +buildNumber=1851 diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperEngine.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperEngine.kt index d4112779..02dd320e 100644 --- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperEngine.kt +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperEngine.kt @@ -143,11 +143,33 @@ class WhisperEngine(private val context: Context) { val inputTensor = interpreter.getInputTensor(0) val outputTensor = interpreter.getOutputTensor(0) - // Prepare input buffer - val inputSize = inputTensor.shape()[0] * inputTensor.shape()[1] * inputTensor.shape()[2] * Float.SIZE_BYTES / 8 - val inputBuffer = ByteBuffer.allocateDirect(inputSize).apply { + Log.d(TAG, "Input tensor shape: ${inputTensor.shape().contentToString()}") + Log.d(TAG, "Mel spectrogram size: ${melSpectrogram.size}") + + // Prepare input buffer - calculate correct size + // Shape is typically [1, n_mels, n_frames] = [1, 80, 3000] + val inputShape = inputTensor.shape() + val expectedInputSize = inputShape.fold(1) { acc, dim -> acc * dim } + val inputBufferSize = expectedInputSize * 4 // 4 bytes per float + + Log.d(TAG, "Expected input size: $expectedInputSize floats, buffer size: $inputBufferSize bytes") + Log.d(TAG, "Actual mel spectrogram size: ${melSpectrogram.size} floats") + + // Adjust mel spectrogram size if needed + val adjustedMel = if (melSpectrogram.size != expectedInputSize) { + Log.w(TAG, "Mel spectrogram size mismatch! Expected $expectedInputSize, got ${melSpectrogram.size}") + FloatArray(expectedInputSize).also { + val copySize = minOf(melSpectrogram.size, expectedInputSize) + System.arraycopy(melSpectrogram, 0, it, 0, copySize) + } + } else { + melSpectrogram + } + + // Create input buffer + val inputBuffer = ByteBuffer.allocateDirect(inputBufferSize).apply { order(ByteOrder.nativeOrder()) - melSpectrogram.forEach { putFloat(it) } + adjustedMel.forEach { putFloat(it) } rewind() } From 03801d898ba9786dabe9903664523c970f458563 Mon Sep 17 00:00:00 2001 From: Patrick Zauner <74716024+pzauner@users.noreply.github.com> Date: Mon, 5 Jan 2026 00:52:23 +0100 Subject: [PATCH 13/14] feat: Complete Whisper speech recognition implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement OpenAI Whisper API integration with cloud-based transcription - Implement OpenRouter Audio API with multiple model support and pricing - Add local ONNX Whisper support (WIP) with DocWolle models - Implement comprehensive usage statistics tracking: * Total cost tracking (USD) * Word count aggregation * Words-per-minute (WPM) calculation * Per-model breakdown with usage metrics - Add system language fallback for automatic locale detection - Add model compatibility warnings for unsupported variants - Integrate ONNX Runtime (v1.17.0) for local inference - Support multiple audio formats (WAV, MP3, ONNX) - Auto-enable speech engines on selection - Remove redundant toggles from sub-screens - Remove Audio Debug menu and related UI - Clean up development documentation files Features: ✅ Three speech recognition engines: Google Stock, OpenAI API, OpenRouter API ✅ Dynamic model listing with real-time pricing from OpenRouter ✅ API key validation with visual feedback ✅ Usage statistics with model-level breakdown ✅ Proper error handling and logging ✅ Graceful fallbacks for missing configurations Tested with: - Google Flash models on OpenRouter - OpenAI Whisper API - System language detection across locales --- .kotlin/errors/errors-1767570026135.log | 176 ++ app/build.gradle.kts | 2 + app/build.properties | 6 +- .../pastiera/AdvancedSettingsScreen.kt | 1 + .../palsoftware/pastiera/SettingsManager.kt | 190 ++- .../pastiera/WhisperSettingsScreen.kt | 1439 ++++++++++++++--- .../PhysicalKeyboardInputMethodService.kt | 248 ++- .../inputmethod/whisper/AudioDebugHelper.kt | 103 ++ .../whisper/OpenAiWhisperClient.kt | 161 ++ .../OpenAiWhisperRecognitionManager.kt | 378 +++++ .../whisper/OpenRouterWhisperClient.kt | 318 ++++ .../OpenRouterWhisperRecognitionManager.kt | 372 +++++ .../inputmethod/whisper/UsageStats.kt | 139 ++ .../inputmethod/whisper/UsageStatsUI.kt | 395 +++++ .../inputmethod/whisper/WavAudioWriter.kt | 152 ++ .../inputmethod/whisper/WhisperModel.kt | 44 +- .../whisper/WhisperModelDownloader.kt | 93 +- .../inputmethod/whisper/WhisperOnnxManager.kt | 271 ++++ .../whisper/WhisperRecognitionManager.kt | 204 ++- .../inputmethod/whisper/WhisperRecorder.kt | 58 +- .../inputmethod/whisper/WhisperTranscriber.kt | 334 ++++ app/src/main/res/values/strings.xml | 23 + 22 files changed, 4696 insertions(+), 411 deletions(-) create mode 100644 .kotlin/errors/errors-1767570026135.log create mode 100644 app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/AudioDebugHelper.kt create mode 100644 app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/OpenAiWhisperClient.kt create mode 100644 app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/OpenAiWhisperRecognitionManager.kt create mode 100644 app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/OpenRouterWhisperClient.kt create mode 100644 app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/OpenRouterWhisperRecognitionManager.kt create mode 100644 app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/UsageStats.kt create mode 100644 app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/UsageStatsUI.kt create mode 100644 app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WavAudioWriter.kt create mode 100644 app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperOnnxManager.kt create mode 100644 app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperTranscriber.kt diff --git a/.kotlin/errors/errors-1767570026135.log b/.kotlin/errors/errors-1767570026135.log new file mode 100644 index 00000000..30cf57e8 --- /dev/null +++ b/.kotlin/errors/errors-1767570026135.log @@ -0,0 +1,176 @@ +kotlin version: 2.0.21 +error message: Daemon compilation failed: null +java.lang.Exception + at org.jetbrains.kotlin.daemon.common.CompileService$CallResult$Error.get(CompileService.kt:69) + at org.jetbrains.kotlin.daemon.common.CompileService$CallResult$Error.get(CompileService.kt:65) + at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemon(GradleKotlinCompilerWork.kt:240) + at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemonOrFallbackImpl(GradleKotlinCompilerWork.kt:159) + at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.run(GradleKotlinCompilerWork.kt:111) + at org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction.execute(GradleCompilerRunnerWithWorkers.kt:76) + at org.gradle.workers.internal.DefaultWorkerServer.execute(DefaultWorkerServer.java:63) + at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:66) + at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:62) + at org.gradle.internal.classloader.ClassLoaderUtils.executeInClassloader(ClassLoaderUtils.java:100) + at org.gradle.workers.internal.NoIsolationWorkerFactory$1.lambda$execute$0(NoIsolationWorkerFactory.java:62) + at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:44) + at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:41) + at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:210) + at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:205) + at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:67) + at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:60) + at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:167) + at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:60) + at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:54) + at org.gradle.workers.internal.AbstractWorker.executeWrappedInBuildOperation(AbstractWorker.java:41) + at org.gradle.workers.internal.NoIsolationWorkerFactory$1.execute(NoIsolationWorkerFactory.java:59) + at org.gradle.workers.internal.DefaultWorkerExecutor.lambda$submitWork$0(DefaultWorkerExecutor.java:174) + at java.base/java.util.concurrent.FutureTask.run(Unknown Source) + at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runExecution(DefaultConditionalExecutionQueue.java:194) + at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.access$700(DefaultConditionalExecutionQueue.java:127) + at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner$1.run(DefaultConditionalExecutionQueue.java:169) + at org.gradle.internal.Factories$1.create(Factories.java:31) + at org.gradle.internal.work.DefaultWorkerLeaseService.withLocks(DefaultWorkerLeaseService.java:263) + at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:127) + at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:132) + at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runBatch(DefaultConditionalExecutionQueue.java:164) + at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.run(DefaultConditionalExecutionQueue.java:133) + at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Unknown Source) + at java.base/java.util.concurrent.FutureTask.run(Unknown Source) + at org.gradle.internal.concurrent.ExecutorPolicy$CatchAndRecordFailures.onExecute(ExecutorPolicy.java:64) + at org.gradle.internal.concurrent.AbstractManagedExecutor$1.run(AbstractManagedExecutor.java:48) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source) + at java.base/java.lang.Thread.run(Unknown Source) +Caused by: java.lang.IllegalStateException: Storage for [/Users/user/gits/GitHub/pastiera/app/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab] is already registered + at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:410) + at org.jetbrains.kotlin.com.intellij.util.io.PagedFileStorage.(PagedFileStorage.java:72) + at org.jetbrains.kotlin.com.intellij.util.io.ResizeableMappedFile.(ResizeableMappedFile.java:55) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentBTreeEnumerator.(PersistentBTreeEnumerator.java:128) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentEnumerator.createDefaultEnumerator(PersistentEnumerator.java:52) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.(PersistentMapImpl.java:165) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.(PersistentMapImpl.java:140) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.buildImplementation(PersistentMapBuilder.java:88) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.build(PersistentMapBuilder.java:71) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.(PersistentHashMap.java:45) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.(PersistentHashMap.java:71) + at org.jetbrains.kotlin.incremental.storage.LazyStorage.createMap(LazyStorage.kt:62) + at org.jetbrains.kotlin.incremental.storage.LazyStorage.getStorageIfExists(LazyStorage.kt:53) + at org.jetbrains.kotlin.incremental.storage.LazyStorage.contains(LazyStorage.kt:72) + at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.set(InMemoryStorage.kt:83) + at org.jetbrains.kotlin.incremental.storage.ClassAttributesMap.set(ClassAttributesMap.kt:45) + at org.jetbrains.kotlin.incremental.AbstractIncrementalCache.addToClassStorage(AbstractIncrementalCache.kt:146) + at org.jetbrains.kotlin.incremental.IncrementalJvmCache.saveClassToCache(IncrementalJvmCache.kt:193) + at org.jetbrains.kotlin.incremental.IncrementalJvmCache.saveFileToCache(IncrementalJvmCache.kt:119) + at org.jetbrains.kotlin.incremental.BuildUtilKt.updateIncrementalCache(buildUtil.kt:110) + at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.updateCaches(IncrementalJvmCompilerRunner.kt:372) + at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.updateCaches(IncrementalJvmCompilerRunner.kt:73) + at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.doCompile(IncrementalCompilerRunner.kt:546) + at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileImpl(IncrementalCompilerRunner.kt:423) + at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileNonIncrementally(IncrementalCompilerRunner.kt:301) + at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compile(IncrementalCompilerRunner.kt:129) + at org.jetbrains.kotlin.daemon.CompileServiceImplBase.execIncrementalCompiler(CompileServiceImpl.kt:675) + at org.jetbrains.kotlin.daemon.CompileServiceImplBase.access$execIncrementalCompiler(CompileServiceImpl.kt:92) + at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1660) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source) + at java.base/java.lang.reflect.Method.invoke(Unknown Source) + at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(Unknown Source) + at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source) + at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source) + at java.base/java.security.AccessController.doPrivileged(Unknown Source) + at java.rmi/sun.rmi.transport.Transport.serviceCall(Unknown Source) + at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(Unknown Source) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(Unknown Source) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(Unknown Source) + at java.base/java.security.AccessController.doPrivileged(Unknown Source) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(Unknown Source) + ... 3 more + Suppressed: java.lang.Exception: Storage[/Users/user/gits/GitHub/pastiera/app/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab] registration stack trace + at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:437) + at org.jetbrains.kotlin.com.intellij.util.io.PagedFileStorage.(PagedFileStorage.java:72) + at org.jetbrains.kotlin.com.intellij.util.io.ResizeableMappedFile.(ResizeableMappedFile.java:55) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentBTreeEnumerator.(PersistentBTreeEnumerator.java:128) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentEnumerator.createDefaultEnumerator(PersistentEnumerator.java:52) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.(PersistentMapImpl.java:165) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.(PersistentMapImpl.java:140) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.buildImplementation(PersistentMapBuilder.java:88) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.build(PersistentMapBuilder.java:71) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.(PersistentHashMap.java:45) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.(PersistentHashMap.java:71) + at org.jetbrains.kotlin.incremental.storage.LazyStorage.createMap(LazyStorage.kt:62) + at org.jetbrains.kotlin.incremental.storage.LazyStorage.getStorageOrCreateNew(LazyStorage.kt:59) + at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80) + at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108) + at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136) + at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.close(PersistentStorage.kt:111) + at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53) + at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53) + at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87) + at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.close(BasicMapsOwner.kt:53) + at org.jetbrains.kotlin.com.google.common.io.Closer.close(Closer.java:223) + at org.jetbrains.kotlin.incremental.IncrementalCachesManager.close(IncrementalCachesManager.kt:55) + at kotlin.io.CloseableKt.closeFinally(Closeable.kt:56) + at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileNonIncrementally(IncrementalCompilerRunner.kt:293) + ... 19 more + Suppressed: java.lang.AssertionError: java.lang.Exception: Could not close incremental caches in /Users/user/gits/GitHub/pastiera/app/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs: source-to-output.tab + at org.jetbrains.kotlin.com.google.common.io.Closer.close(Closer.java:236) + at org.jetbrains.kotlin.incremental.IncrementalCachesManager.close(IncrementalCachesManager.kt:55) + at kotlin.io.CloseableKt.closeFinally(Closeable.kt:59) + at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileNonIncrementally(IncrementalCompilerRunner.kt:293) + ... 19 more + Caused by: java.lang.Exception: Could not close incremental caches in /Users/user/gits/GitHub/pastiera/app/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs: source-to-output.tab + at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:95) + at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.close(BasicMapsOwner.kt:53) + at org.jetbrains.kotlin.com.google.common.io.Closer.close(Closer.java:223) + ... 22 more + Suppressed: java.lang.IllegalStateException: Storage for [/Users/user/gits/GitHub/pastiera/app/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab] is already registered + at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:410) + at org.jetbrains.kotlin.com.intellij.util.io.PagedFileStorage.(PagedFileStorage.java:72) + at org.jetbrains.kotlin.com.intellij.util.io.ResizeableMappedFile.(ResizeableMappedFile.java:55) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentBTreeEnumerator.(PersistentBTreeEnumerator.java:128) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentEnumerator.createDefaultEnumerator(PersistentEnumerator.java:52) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.(PersistentMapImpl.java:165) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.(PersistentMapImpl.java:140) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.buildImplementation(PersistentMapBuilder.java:88) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.build(PersistentMapBuilder.java:71) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.(PersistentHashMap.java:45) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.(PersistentHashMap.java:71) + at org.jetbrains.kotlin.incremental.storage.LazyStorage.createMap(LazyStorage.kt:62) + at org.jetbrains.kotlin.incremental.storage.LazyStorage.getStorageOrCreateNew(LazyStorage.kt:59) + at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80) + at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108) + at org.jetbrains.kotlin.incremental.storage.AppendableInMemoryStorage.applyChanges(InMemoryStorage.kt:179) + at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136) + at org.jetbrains.kotlin.incremental.storage.AppendableSetBasicMap.close(BasicMap.kt:157) + at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53) + at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53) + at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87) + ... 24 more + Suppressed: java.lang.Exception: Storage[/Users/user/gits/GitHub/pastiera/app/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab] registration stack trace + at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:437) + at org.jetbrains.kotlin.com.intellij.util.io.PagedFileStorage.(PagedFileStorage.java:72) + at org.jetbrains.kotlin.com.intellij.util.io.ResizeableMappedFile.(ResizeableMappedFile.java:55) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentBTreeEnumerator.(PersistentBTreeEnumerator.java:128) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentEnumerator.createDefaultEnumerator(PersistentEnumerator.java:52) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.(PersistentMapImpl.java:165) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.(PersistentMapImpl.java:140) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.buildImplementation(PersistentMapBuilder.java:88) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.build(PersistentMapBuilder.java:71) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.(PersistentHashMap.java:45) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.(PersistentHashMap.java:71) + at org.jetbrains.kotlin.incremental.storage.LazyStorage.createMap(LazyStorage.kt:62) + at org.jetbrains.kotlin.incremental.storage.LazyStorage.getStorageOrCreateNew(LazyStorage.kt:59) + at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80) + at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108) + at org.jetbrains.kotlin.incremental.storage.AppendableInMemoryStorage.applyChanges(InMemoryStorage.kt:179) + at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136) + at org.jetbrains.kotlin.incremental.storage.AppendableSetBasicMap.close(BasicMap.kt:157) + at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53) + at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53) + at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87) + at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.close(BasicMapsOwner.kt:53) + at org.jetbrains.kotlin.com.google.common.io.Closer.close(Closer.java:223) + at org.jetbrains.kotlin.incremental.IncrementalCachesManager.close(IncrementalCachesManager.kt:55) + at kotlin.io.CloseableKt.closeFinally(Closeable.kt:56) + ... 20 more + + diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5401e30c..5a6ba70c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -214,6 +214,8 @@ dependencies { implementation("org.tensorflow:tensorflow-lite-support:0.4.4") // Voice Activity Detection (VAD) for Whisper implementation("com.github.gkonovalov.android-vad:webrtc:2.0.9") + // ONNX Runtime for Whisper ONNX Models + implementation("com.microsoft.onnxruntime:onnxruntime-android:1.17.0") testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/app/build.properties b/app/build.properties index da6ac104..bb966aad 100644 --- a/app/build.properties +++ b/app/build.properties @@ -1,4 +1,4 @@ #Build number and date -#Sun Jan 04 03:11:04 CET 2026 -buildDate=04 gen 2026 -buildNumber=1851 +#Mon Jan 05 00:51:21 CET 2026 +buildDate=05 gen 2026 +buildNumber=1947 diff --git a/app/src/main/java/it/palsoftware/pastiera/AdvancedSettingsScreen.kt b/app/src/main/java/it/palsoftware/pastiera/AdvancedSettingsScreen.kt index ea766ebb..ed92e2b5 100644 --- a/app/src/main/java/it/palsoftware/pastiera/AdvancedSettingsScreen.kt +++ b/app/src/main/java/it/palsoftware/pastiera/AdvancedSettingsScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size diff --git a/app/src/main/java/it/palsoftware/pastiera/SettingsManager.kt b/app/src/main/java/it/palsoftware/pastiera/SettingsManager.kt index 95153c83..3a1bbfba 100644 --- a/app/src/main/java/it/palsoftware/pastiera/SettingsManager.kt +++ b/app/src/main/java/it/palsoftware/pastiera/SettingsManager.kt @@ -857,7 +857,31 @@ object SettingsManager { private const val KEY_USE_WHISPER = "use_whisper_speech_recognition" private const val KEY_WHISPER_MODEL = "whisper_model" private const val DEFAULT_USE_WHISPER = false - private const val DEFAULT_WHISPER_MODEL = "BASE" // WhisperModel enum name + private const val DEFAULT_WHISPER_MODEL = "SMALL" // WhisperModel enum name (DocWolle ONNX small_int8) + + // OpenAI API Integration Keys + private const val KEY_WHISPER_MODE = "whisper_mode" // "local", "api" + private const val KEY_USE_OPENAI_API = "use_openai_api" + private const val KEY_OPENAI_API_KEY = "openai_api_key" + private const val KEY_OPENAI_MODEL = "openai_model" + private const val KEY_OPENAI_API_LANGUAGE = "openai_api_language" + private const val KEY_OPENAI_API_PROMPT = "openai_api_prompt" + private const val KEY_OPENAI_API_TEMPERATURE = "openai_api_temperature" + + private const val DEFAULT_WHISPER_MODE = "local" // "local" or "api" + private const val DEFAULT_USE_OPENAI_API = false + private const val DEFAULT_OPENAI_MODEL = "gpt-4o-transcribe" + private const val DEFAULT_OPENAI_API_LANGUAGE = "de" // German by default + private const val DEFAULT_OPENAI_API_PROMPT = "" + private const val DEFAULT_OPENAI_API_TEMPERATURE = 0f + + // OpenRouter API Integration Keys + private const val KEY_OPENROUTER_API_KEY = "openrouter_api_key" + private const val KEY_OPENROUTER_MODEL = "openrouter_model" + private const val KEY_OPENROUTER_LANGUAGE = "openrouter_language" + + private const val DEFAULT_OPENROUTER_MODEL = "google/gemini-2.5-flash" + private const val DEFAULT_OPENROUTER_LANGUAGE = "de" /** * Gets whether Whisper should be used instead of Google Speech Recognition. @@ -884,7 +908,7 @@ object SettingsManager { it.palsoftware.pastiera.inputmethod.whisper.WhisperModel.valueOf(modelName) } catch (e: IllegalArgumentException) { Log.w(TAG, "Invalid Whisper model name: $modelName, using default") - it.palsoftware.pastiera.inputmethod.whisper.WhisperModel.BASE + it.palsoftware.pastiera.inputmethod.whisper.WhisperModel.SMALL } } @@ -897,6 +921,168 @@ object SettingsManager { .apply() } + /** + * Gets the Whisper mode: "local" or "api" + */ + fun getWhisperMode(context: Context): String { + return getPreferences(context).getString(KEY_WHISPER_MODE, DEFAULT_WHISPER_MODE) ?: DEFAULT_WHISPER_MODE + } + + /** + * Sets the Whisper mode: "local" or "api" + */ + fun setWhisperMode(context: Context, mode: String) { + getPreferences(context).edit() + .putString(KEY_WHISPER_MODE, mode) + .apply() + } + + /** + * Gets whether to use OpenAI API for Whisper + */ + fun getUseOpenAiApi(context: Context): Boolean { + return getPreferences(context).getBoolean(KEY_USE_OPENAI_API, DEFAULT_USE_OPENAI_API) + } + + /** + * Sets whether to use OpenAI API for Whisper + */ + fun setUseOpenAiApi(context: Context, useApi: Boolean) { + getPreferences(context).edit() + .putBoolean(KEY_USE_OPENAI_API, useApi) + .apply() + } + + /** + * Gets the OpenAI API key (stored encrypted) + */ + fun getOpenAiApiKey(context: Context): String { + return getPreferences(context).getString(KEY_OPENAI_API_KEY, "") ?: "" + } + + /** + * Sets the OpenAI API key (should be encrypted before storage) + */ + fun setOpenAiApiKey(context: Context, apiKey: String) { + getPreferences(context).edit() + .putString(KEY_OPENAI_API_KEY, apiKey) + .apply() + } + + /** + * Gets the OpenAI model to use (e.g., "gpt-4o-transcribe", "gpt-4o-mini-transcribe", "whisper-1") + */ + fun getOpenAiModel(context: Context): String { + return getPreferences(context).getString(KEY_OPENAI_MODEL, DEFAULT_OPENAI_MODEL) ?: DEFAULT_OPENAI_MODEL + } + + /** + * Sets the OpenAI model to use + */ + fun setOpenAiModel(context: Context, model: String) { + getPreferences(context).edit() + .putString(KEY_OPENAI_MODEL, model) + .apply() + } + + /** + * Gets the language code for OpenAI API transcription + */ + fun getOpenAiLanguage(context: Context): String { + return getPreferences(context).getString(KEY_OPENAI_API_LANGUAGE, DEFAULT_OPENAI_API_LANGUAGE) ?: DEFAULT_OPENAI_API_LANGUAGE + } + + /** + * Sets the language code for OpenAI API transcription + */ + fun setOpenAiLanguage(context: Context, language: String) { + getPreferences(context).edit() + .putString(KEY_OPENAI_API_LANGUAGE, language) + .apply() + } + + /** + * Gets the prompt hint for OpenAI API transcription + */ + fun getOpenAiPrompt(context: Context): String { + return getPreferences(context).getString(KEY_OPENAI_API_PROMPT, DEFAULT_OPENAI_API_PROMPT) ?: DEFAULT_OPENAI_API_PROMPT + } + + /** + * Sets the prompt hint for OpenAI API transcription + */ + fun setOpenAiPrompt(context: Context, prompt: String) { + getPreferences(context).edit() + .putString(KEY_OPENAI_API_PROMPT, prompt) + .apply() + } + + /** + * Gets the temperature for OpenAI API transcription + */ + fun getOpenAiTemperature(context: Context): Float { + return getPreferences(context).getFloat(KEY_OPENAI_API_TEMPERATURE, DEFAULT_OPENAI_API_TEMPERATURE) + } + + /** + * Sets the temperature for OpenAI API transcription + */ + fun setOpenAiTemperature(context: Context, temperature: Float) { + getPreferences(context).edit() + .putFloat(KEY_OPENAI_API_TEMPERATURE, temperature) + .apply() + } + + // ===== OpenRouter API Methods ===== + + /** + * Gets the OpenRouter API key + */ + fun getOpenRouterApiKey(context: Context): String { + return getPreferences(context).getString(KEY_OPENROUTER_API_KEY, "") ?: "" + } + + /** + * Sets the OpenRouter API key + */ + fun setOpenRouterApiKey(context: Context, apiKey: String) { + getPreferences(context).edit() + .putString(KEY_OPENROUTER_API_KEY, apiKey) + .apply() + } + + /** + * Gets the selected OpenRouter model + */ + fun getOpenRouterModel(context: Context): String { + return getPreferences(context).getString(KEY_OPENROUTER_MODEL, DEFAULT_OPENROUTER_MODEL) ?: DEFAULT_OPENROUTER_MODEL + } + + /** + * Sets the selected OpenRouter model + */ + fun setOpenRouterModel(context: Context, model: String) { + getPreferences(context).edit() + .putString(KEY_OPENROUTER_MODEL, model) + .apply() + } + + /** + * Gets the language for OpenRouter transcription + */ + fun getOpenRouterLanguage(context: Context): String { + return getPreferences(context).getString(KEY_OPENROUTER_LANGUAGE, DEFAULT_OPENROUTER_LANGUAGE) ?: DEFAULT_OPENROUTER_LANGUAGE + } + + /** + * Sets the language for OpenRouter transcription + */ + fun setOpenRouterLanguage(context: Context, language: String) { + getPreferences(context).edit() + .putString(KEY_OPENROUTER_LANGUAGE, language) + .apply() + } + /** * Returns true if long press uses Shift, false if it uses Alt. */ diff --git a/app/src/main/java/it/palsoftware/pastiera/WhisperSettingsScreen.kt b/app/src/main/java/it/palsoftware/pastiera/WhisperSettingsScreen.kt index 71e3fbc5..faceb467 100644 --- a/app/src/main/java/it/palsoftware/pastiera/WhisperSettingsScreen.kt +++ b/app/src/main/java/it/palsoftware/pastiera/WhisperSettingsScreen.kt @@ -1,23 +1,40 @@ package it.palsoftware.pastiera +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.filled.CloudDownload import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Info import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import it.palsoftware.pastiera.inputmethod.whisper.WhisperModel import it.palsoftware.pastiera.inputmethod.whisper.WhisperModelDownloader +import it.palsoftware.pastiera.inputmethod.whisper.OpenAiWhisperClient +import it.palsoftware.pastiera.inputmethod.whisper.UsageStatsCard import kotlinx.coroutines.launch +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.withContext +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -28,12 +45,31 @@ fun WhisperSettingsScreen( val context = LocalContext.current val scope = rememberCoroutineScope() + // Navigation state + var currentScreen by remember { mutableStateOf(WhisperScreen.Main) } + + // Engine selection (Meta-Menu) + var selectedEngine by remember { mutableStateOf(SettingsManager.getWhisperMode(context)) } + + // OpenRouter settings + var openRouterApiKey by remember { mutableStateOf(SettingsManager.getOpenRouterApiKey(context)) } + var openRouterModel by remember { mutableStateOf(SettingsManager.getOpenRouterModel(context)) } + var openRouterLanguage by remember { mutableStateOf(SettingsManager.getOpenRouterLanguage(context)) } + + // Local Whisper settings var useWhisper by remember { mutableStateOf(SettingsManager.getUseWhisper(context)) } var selectedModel by remember { mutableStateOf(SettingsManager.getWhisperModel(context)) } var downloadingModel by remember { mutableStateOf(null) } var downloadProgress by remember { mutableStateOf(0f) } var downloadedModels by remember { mutableStateOf(setOf()) } + // OpenAI API settings + var useOpenAiApi by remember { mutableStateOf(SettingsManager.getUseOpenAiApi(context)) } + var openAiApiKey by remember { mutableStateOf(SettingsManager.getOpenAiApiKey(context)) } + var openAiModel by remember { mutableStateOf(SettingsManager.getOpenAiModel(context)) } + var openAiLanguage by remember { mutableStateOf(SettingsManager.getOpenAiLanguage(context)) } + var openAiPrompt by remember { mutableStateOf(SettingsManager.getOpenAiPrompt(context)) } + val downloader = remember { WhisperModelDownloader(context) } // Check which models are downloaded @@ -41,6 +77,19 @@ fun WhisperSettingsScreen( downloadedModels = WhisperModel.values().filter { downloader.isModelDownloaded(it) }.toSet() } + // Navigation handler + fun navigateTo(screen: WhisperScreen) { + currentScreen = screen + } + + fun navigateBack() { + if (currentScreen != WhisperScreen.Main) { + currentScreen = WhisperScreen.Main + } else { + onBack() + } + } + Scaffold( topBar = { Surface( @@ -55,7 +104,7 @@ fun WhisperSettingsScreen( .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically ) { - IconButton(onClick = onBack) { + IconButton(onClick = { navigateBack() }) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.settings_back_content_description) @@ -75,232 +124,1019 @@ fun WhisperSettingsScreen( modifier = modifier .fillMaxSize() .padding(paddingValues) - .verticalScroll(rememberScrollState()) ) { - // Use Whisper Toggle - Surface( + // Screen Content + when (currentScreen) { + WhisperScreen.Main -> WhisperMainScreen( + modifier = Modifier.fillMaxSize(), + selectedEngine = selectedEngine, + onEngineSelected = { engine -> + selectedEngine = engine + SettingsManager.setWhisperMode(context, engine) + }, + onLocalSettingsClick = { navigateTo(WhisperScreen.Local) }, + onOpenAiSettingsClick = { navigateTo(WhisperScreen.OpenAi) }, + onOpenRouterSettingsClick = { navigateTo(WhisperScreen.OpenRouter) } + ) + + WhisperScreen.Local -> WhisperLocalTab( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + selectedModel = selectedModel, + onModelSelected = { + selectedModel = it + SettingsManager.setWhisperModel(context, it) + }, + downloadedModels = downloadedModels, + downloadingModel = downloadingModel, + onDownloadingModelChange = { downloadingModel = it }, + downloadProgress = downloadProgress, + onDownloadProgressChange = { downloadProgress = it }, + downloader = downloader, + onDownloadedModelsChange = { downloadedModels = it }, + scope = scope, + context = context + ) + + WhisperScreen.OpenRouter -> WhisperOpenRouterTab( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + apiKey = openRouterApiKey, + onApiKeyChange = { + openRouterApiKey = it + SettingsManager.setOpenRouterApiKey(context, it) + }, + model = openRouterModel, + onModelChange = { + openRouterModel = it + SettingsManager.setOpenRouterModel(context, it) + }, + language = openRouterLanguage, + onLanguageChange = { + openRouterLanguage = it + SettingsManager.setOpenRouterLanguage(context, it) + }, + context = context + ) + + WhisperScreen.OpenAi -> WhisperOpenAiTab( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + apiKey = openAiApiKey, + onApiKeyChange = { + openAiApiKey = it + SettingsManager.setOpenAiApiKey(context, it) + }, + model = openAiModel, + onModelChange = { + openAiModel = it + SettingsManager.setOpenAiModel(context, it) + }, + language = openAiLanguage, + onLanguageChange = { + openAiLanguage = it + SettingsManager.setOpenAiLanguage(context, it) + }, + prompt = openAiPrompt, + onPromptChange = { + openAiPrompt = it + SettingsManager.setOpenAiPrompt(context, it) + }, + context = context + ) + } + } + } +} + +@Composable +private fun WhisperMainScreen( + modifier: Modifier = Modifier, + selectedEngine: String, + onEngineSelected: (String) -> Unit, + onLocalSettingsClick: () -> Unit, + onOpenAiSettingsClick: () -> Unit, + onOpenRouterSettingsClick: () -> Unit = {} +) { + val context = LocalContext.current + + Column( + modifier = modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Info + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.small + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Speech Recognition Engine", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + Text( + text = "Choose your preferred speech-to-text engine. Selection is automatically enabled.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Google Stock Option + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .clickable { + onEngineSelected("google") + } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + RadioButton( + selected = selectedEngine == "google", + onClick = { onEngineSelected("google") } + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Google Speech API", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + Text( + text = "Android stock speech recognition • Always available", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + // OpenAI Whisper API Option + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .clickable { + onEngineSelected("api") + onOpenAiSettingsClick() + } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + RadioButton( + selected = selectedEngine == "api", + onClick = { onEngineSelected("api") } + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "OpenAI Whisper API", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + Text( + text = "Cloud-based • Premium quality • Requires API key & internet", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // OpenRouter Audio API Option + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .clickable { + onEngineSelected("openrouter") + onOpenRouterSettingsClick() + } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + RadioButton( + selected = selectedEngine == "openrouter", + onClick = { onEngineSelected("openrouter") } + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "OpenRouter Audio", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + Text( + text = "Multiple models • Cloud-based • Requires API key & internet", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Spacer before coming-soon features + Spacer(modifier = Modifier.height(8.dp)) + + // Local Whisper Option (Coming Soon - Disabled) + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .alpha(0.6f) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + RadioButton( + selected = false, + onClick = {}, + enabled = false + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Whisper (Local ONNX)", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + Text( + text = "Offline speech recognition • Coming soon", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + // Local Whisper WIP Info + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.small + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Filled.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Text( + text = "Local Whisper – Work in Progress", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary + ) + } + Text( + text = "Local Whisper support is currently being developed and tested. We're working on integrating DocWolle's optimized ONNX models.", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp) + ) + // Clickable link to download models + Text( + text = "Download DocWolle Whisper ONNX Models", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .clickable { + val intent = android.content.Intent( + android.content.Intent.ACTION_VIEW, + android.net.Uri.parse("https://huggingface.co/DocWolle/whisperOnnx") + ) + context.startActivity(intent) + } + .padding(4.dp), + textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline + ) + } + } + } +} + +@Composable +private fun WhisperLocalTab( + modifier: Modifier = Modifier, + selectedModel: WhisperModel, + onModelSelected: (WhisperModel) -> Unit, + downloadedModels: Set, + downloadingModel: WhisperModel?, + onDownloadingModelChange: (WhisperModel?) -> Unit, + downloadProgress: Float, + onDownloadProgressChange: (Float) -> Unit, + downloader: WhisperModelDownloader, + onDownloadedModelsChange: (Set) -> Unit, + scope: CoroutineScope, + context: android.content.Context +) { + Column(modifier = modifier.padding(16.dp)) { + // Model Selection + Text( + text = stringResource(R.string.whisper_model_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + Text( + text = stringResource(R.string.whisper_model_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp, bottom = 16.dp) + ) + + // Model Cards + WhisperModel.values().forEach { model -> + val isDownloaded = downloadedModels.contains(model) + val isSelected = selectedModel == model + val isDownloadingThis = downloadingModel == model + + ElevatedCard( modifier = Modifier .fillMaxWidth() - .height(72.dp) + .padding(vertical = 8.dp), + onClick = { + if (isDownloaded) { + onModelSelected(model) + } + } ) { Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically ) { - Column(modifier = Modifier.weight(1f)) { + RadioButton( + selected = isSelected, + onClick = { + if (isDownloaded) { + onModelSelected(model) + } + }, + enabled = isDownloaded + ) + + Column( + modifier = Modifier + .weight(1f) + .padding(start = 12.dp) + ) { Text( - text = stringResource(R.string.whisper_enabled_title), - style = MaterialTheme.typography.titleMedium, + text = model.displayName, + style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Medium ) Text( - text = stringResource(R.string.whisper_enabled_description), + text = model.description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) + Text( + text = "${model.sizeBytes / (1024 * 1024)} MB", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 4.dp) + ) + if (isDownloaded) { + Text( + text = stringResource(R.string.whisper_model_status_downloaded), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.tertiary + ) + } else if (isDownloadingThis) { + Text( + text = stringResource(R.string.whisper_model_download_in_progress) + " ${(downloadProgress * 100).toInt()}%", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary + ) + LinearProgressIndicator( + progress = { downloadProgress }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp) + ) + } + } + + // Download/Delete Button + if (isDownloaded) { + IconButton( + onClick = { + downloader.deleteModel(model) + onDownloadedModelsChange(downloadedModels - model) + } + ) { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = "Delete", + tint = MaterialTheme.colorScheme.error + ) + } + } else { + IconButton( + onClick = { + if (downloadingModel == null) { + onDownloadingModelChange(model) + onDownloadProgressChange(0f) + scope.launch { + try { + android.util.Log.d("WhisperSettings", "Starting download for ${model.displayName}") + + val result = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + downloader.downloadModel( + model, + object : WhisperModelDownloader.DownloadListener { + override fun onProgress(bytesDownloaded: Long, totalBytes: Long) { + onDownloadProgressChange(bytesDownloaded.toFloat() / totalBytes) + } + override fun onComplete(file: java.io.File) { + android.util.Log.d("WhisperSettings", "Download complete: ${file.absolutePath}") + } + override fun onError(error: String) { + android.util.Log.e("WhisperSettings", "Download error: $error") + } + } + ) + } + + onDownloadingModelChange(null) + if (result.isSuccess) { + onDownloadedModelsChange(downloadedModels + model) + android.widget.Toast.makeText( + context, + context.getString(R.string.whisper_model_download_complete), + android.widget.Toast.LENGTH_SHORT + ).show() + } else { + val errorMsg = result.exceptionOrNull()?.message ?: "Unknown error" + android.widget.Toast.makeText( + context, + "${context.getString(R.string.whisper_model_download_failed)}: $errorMsg", + android.widget.Toast.LENGTH_LONG + ).show() + } + } catch (e: Exception) { + onDownloadingModelChange(null) + android.widget.Toast.makeText( + context, + "Error: ${e.message}", + android.widget.Toast.LENGTH_LONG + ).show() + } + } + } + }, + enabled = downloadingModel == null + ) { + if (isDownloadingThis) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp + ) + } else { + Icon( + imageVector = Icons.Filled.CloudDownload, + contentDescription = "Download" + ) + } + } } - Switch( - checked = useWhisper, - onCheckedChange = { - useWhisper = it - SettingsManager.setUseWhisper(context, it) - }, - enabled = downloadedModels.contains(selectedModel) - ) } } + } + } +} + +@Composable +private fun WhisperOpenAiTab( + modifier: Modifier = Modifier, + apiKey: String, + onApiKeyChange: (String) -> Unit, + model: String, + onModelChange: (String) -> Unit, + language: String, + onLanguageChange: (String) -> Unit, + prompt: String, + onPromptChange: (String) -> Unit, + context: android.content.Context +) { + Column(modifier = modifier.padding(16.dp)) { + // Info + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.small + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top + ) { + Icon( + imageVector = Icons.Filled.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Text( + text = stringResource(R.string.openai_info), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // API Key + Text( + text = stringResource(R.string.openai_api_key_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + var isValidating by remember { mutableStateOf(false) } + var isOpenAiValid by remember { mutableStateOf(false) } + val openAiScope = rememberCoroutineScope() + + LaunchedEffect(apiKey) { + if (apiKey.isNotEmpty() && apiKey.length > 20) { + isValidating = true + openAiScope.launch(kotlinx.coroutines.Dispatchers.IO) { // RUN ON IO THREAD! + try { + Log.d("OpenAiValidation", "Starting validation for API key length: ${apiKey.length}") + val client = OpenAiWhisperClient(apiKey) + val result = client.validateApiKey() + Log.d("OpenAiValidation", "Validation result: ${result.isSuccess}") + + withContext(kotlinx.coroutines.Dispatchers.Main) { + isOpenAiValid = result.isSuccess + isValidating = false + } + } catch (e: Exception) { + Log.e("OpenAiValidation", "Validation error: ${e.message}") + Log.e("OpenAiValidation", "Stack trace: ${e.stackTraceToString()}") + withContext(kotlinx.coroutines.Dispatchers.Main) { + isOpenAiValid = false + isValidating = false + } + } + } + } else { + isOpenAiValid = false + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = apiKey, + onValueChange = onApiKeyChange, + modifier = Modifier.weight(1f), + placeholder = { Text(stringResource(R.string.openai_api_key_placeholder)) }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password + ) + ) - HorizontalDivider() + // Status icon - only show when validated + if (isOpenAiValid) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = "API Key valid", + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(24.dp) + ) + } else if (isValidating) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Model Selection + Text( + text = stringResource(R.string.openai_model_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(top = 8.dp) + ) + + val models = listOf( + "gpt-4o-transcribe" to "GPT-4o Transcribe (Best Quality)", + "gpt-4o-mini-transcribe" to "GPT-4o Mini Transcribe (Fast)", + "whisper-1" to "Whisper-1 (Compatible)" + ) + + models.forEach { (modelId, modelLabel) -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = model == modelId, + onClick = { onModelChange(modelId) } + ) + Text( + text = modelLabel, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(start = 12.dp) + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Language + Text( + text = stringResource(R.string.openai_language_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(top = 8.dp) + ) + + OutlinedTextField( + value = language, + onValueChange = onLanguageChange, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + placeholder = { Text("e.g., de, en, fr, it") }, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text + ) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Prompt + Text( + text = stringResource(R.string.openai_prompt_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(top = 8.dp) + ) + Text( + text = stringResource(R.string.openai_prompt_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp, bottom = 8.dp) + ) + + OutlinedTextField( + value = prompt, + onValueChange = onPromptChange, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 80.dp), + placeholder = { Text(stringResource(R.string.openai_prompt_placeholder)) }, + maxLines = 4 + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Usage Statistics + UsageStatsCard( + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Composable +private fun WhisperOpenRouterTab( + modifier: Modifier = Modifier, + apiKey: String, + onApiKeyChange: (String) -> Unit, + model: String, + onModelChange: (String) -> Unit, + language: String, + onLanguageChange: (String) -> Unit, + context: android.content.Context +) { + var availableModels by remember { mutableStateOf>>(emptyList()) } + var isLoadingModels by remember { mutableStateOf(false) } + var loadError by remember { mutableStateOf(null) } + var isValidated by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + // Load models when API key changes + LaunchedEffect(apiKey) { + if (apiKey.isNotEmpty() && apiKey.length > 10) { + isLoadingModels = true + loadError = null + isValidated = false + Log.d("WhisperOpenRouter", "=== LaunchedEffect triggered ===") + Log.d("WhisperOpenRouter", "Validating and loading models for API key: ${apiKey.take(10)}...") + scope.launch(kotlinx.coroutines.Dispatchers.IO) { // RUN ON IO THREAD! + try { + Log.d("WhisperOpenRouter", "About to validate API key (length: ${apiKey.length})") + + // First validate the API key with a simple request + val isValid = validateOpenRouterApiKey(apiKey) + Log.d("WhisperOpenRouter", "Validation result: $isValid") + + if (!isValid) { + withContext(kotlinx.coroutines.Dispatchers.Main) { + isValidated = false + loadError = "Invalid API key. Please check your OpenRouter API key." + isLoadingModels = false + } + Log.e("WhisperOpenRouter", "API key validation failed - stopping here") + return@launch + } + + Log.d("WhisperOpenRouter", "API Key is VALID - proceeding to fetch models") + + // Now fetch models from OpenRouter API + val models = fetchOpenRouterModels(apiKey) + Log.d("WhisperOpenRouter", "Fetched ${models.size} audio-capable models") + + withContext(kotlinx.coroutines.Dispatchers.Main) { + isValidated = true + availableModels = models + if (models.isEmpty()) { + loadError = "No audio-capable models found. This might be a temporary API issue." + Log.w("WhisperOpenRouter", "API returned 0 audio models") + } else { + loadError = null + } + isLoadingModels = false + } + } catch (e: Exception) { + withContext(kotlinx.coroutines.Dispatchers.Main) { + isValidated = false + loadError = "Error loading models: ${e.message}" + isLoadingModels = false + } + Log.e("WhisperOpenRouter", "EXCEPTION during model loading: ${e.message}") + Log.e("WhisperOpenRouter", "Stack trace: ${e.stackTraceToString()}") + } + } + } else { + Log.d("WhisperOpenRouter", "API key too short or empty - not validating") + availableModels = emptyList() + loadError = null + isValidated = false + } + } + + Column(modifier = modifier.padding(16.dp)) { + // Info + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.small + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top + ) { + Icon( + imageVector = Icons.Filled.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Text( + text = "OpenRouter provides access to multiple speech recognition models with transparent pricing.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Model Compatibility Note + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.small + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top + ) { + Icon( + imageVector = Icons.Filled.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Text( + text = "Recommended: Google Flash models are tested and working. Other models support audio but may have compatibility issues.", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // API Key + Text( + text = "API Key", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = apiKey, + onValueChange = onApiKeyChange, + modifier = Modifier.weight(1f), + placeholder = { Text("Enter your OpenRouter API key") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password) + ) - // Model Selection + // Status icon - only show when validated + if (isValidated) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = "API Key valid", + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(24.dp) + ) + } else if (isLoadingModels) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Model Selection + Text( + text = "Model", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + + if (apiKey.isEmpty()) { Surface( modifier = Modifier .fillMaxWidth() - .padding(16.dp) + .padding(top = 8.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.small ) { - Column { - Text( - text = stringResource(R.string.whisper_model_title), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium - ) - Text( - text = stringResource(R.string.whisper_model_description), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp, bottom = 16.dp) - ) - - // Model Cards - WhisperModel.values().forEach { model -> - val isDownloaded = downloadedModels.contains(model) - val isSelected = selectedModel == model - val isDownloadingThis = downloadingModel == model - - ElevatedCard( + Text( + text = "Enter API key to load available models", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(12.dp) + ) + } + } else if (isLoadingModels) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp + ) + Text( + text = "Loading available models...", + style = MaterialTheme.typography.bodySmall + ) + } + } else if (loadError != null) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + color = MaterialTheme.colorScheme.errorContainer, + shape = MaterialTheme.shapes.small + ) { + Text( + text = loadError!!, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(12.dp) + ) + } + } else if (availableModels.isNotEmpty()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + availableModels.forEach { (modelId, modelLabel) -> + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .clickable { onModelChange(modelId) } + ) { + Row( modifier = Modifier .fillMaxWidth() - .padding(vertical = 8.dp), - onClick = { - if (isDownloaded) { - selectedModel = model - SettingsManager.setWhisperModel(context, model) - } - } + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - RadioButton( - selected = isSelected, - onClick = { - if (isDownloaded) { - selectedModel = model - SettingsManager.setWhisperModel(context, model) - } - }, - enabled = isDownloaded + RadioButton( + selected = model == modelId, + onClick = { onModelChange(modelId) } + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = modelLabel.split(" • ")[0], + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium ) - - Column( - modifier = Modifier - .weight(1f) - .padding(start = 12.dp) - ) { + if (modelLabel.contains(" • ")) { Text( - text = model.displayName, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Medium + text = modelLabel.split(" • ", limit = 2)[1], + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) - Text( - text = model.description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = "${model.sizeBytes / (1024 * 1024)} MB", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(top = 4.dp) - ) - if (isDownloaded) { - Text( - text = stringResource(R.string.whisper_model_status_downloaded), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.tertiary - ) - } else if (isDownloadingThis) { - Text( - text = stringResource(R.string.whisper_model_download_in_progress) + " ${(downloadProgress * 100).toInt()}%", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary - ) - LinearProgressIndicator( - progress = { downloadProgress }, - modifier = Modifier - .fillMaxWidth() - .padding(top = 4.dp) - ) - } - } - - // Download/Delete Button - if (isDownloaded) { - IconButton( - onClick = { - downloader.deleteModel(model) - downloadedModels = downloadedModels - model - if (selectedModel == model) { - useWhisper = false - SettingsManager.setUseWhisper(context, false) - } - } - ) { - Icon( - imageVector = Icons.Filled.Delete, - contentDescription = "Delete", - tint = MaterialTheme.colorScheme.error - ) - } - } else { - IconButton( - onClick = { - if (downloadingModel == null) { - downloadingModel = model - downloadProgress = 0f - scope.launch { - try { - android.util.Log.d("WhisperSettings", "Starting download for ${model.displayName}") - - val result = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { - downloader.downloadModel( - model, - object : WhisperModelDownloader.DownloadListener { - override fun onProgress(bytesDownloaded: Long, totalBytes: Long) { - downloadProgress = bytesDownloaded.toFloat() / totalBytes - android.util.Log.d("WhisperSettings", "Progress: $bytesDownloaded / $totalBytes (${(downloadProgress * 100).toInt()}%)") - } - override fun onComplete(file: java.io.File) { - android.util.Log.d("WhisperSettings", "Download complete: ${file.absolutePath}") - } - override fun onError(error: String) { - android.util.Log.e("WhisperSettings", "Download error: $error") - } - } - ) - } - - // Back on Main thread for UI updates - downloadingModel = null - if (result.isSuccess) { - downloadedModels = downloadedModels + model - android.widget.Toast.makeText( - context, - context.getString(R.string.whisper_model_download_complete), - android.widget.Toast.LENGTH_SHORT - ).show() - } else { - val errorMsg = result.exceptionOrNull()?.message ?: "Unknown error" - android.util.Log.e("WhisperSettings", "Download failed: $errorMsg") - android.widget.Toast.makeText( - context, - "${context.getString(R.string.whisper_model_download_failed)}: $errorMsg", - android.widget.Toast.LENGTH_LONG - ).show() - } - android.util.Log.d("WhisperSettings", "Download result: ${result.isSuccess}") - } catch (e: Exception) { - android.util.Log.e("WhisperSettings", "Exception during download", e) - downloadingModel = null - android.widget.Toast.makeText( - context, - "Error: ${e.message}", - android.widget.Toast.LENGTH_LONG - ).show() - } - } - } - }, - enabled = downloadingModel == null - ) { - if (isDownloadingThis) { - androidx.compose.material3.CircularProgressIndicator( - modifier = androidx.compose.ui.Modifier.size(24.dp), - strokeWidth = 2.dp - ) - } else { - Icon( - imageVector = Icons.Filled.CloudDownload, - contentDescription = "Download" - ) - } - } } } } @@ -308,6 +1144,215 @@ fun WhisperSettingsScreen( } } } + + Spacer(modifier = Modifier.height(16.dp)) + + // Language + Text( + text = "Language", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + OutlinedTextField( + value = language, + onValueChange = onLanguageChange, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + placeholder = { Text("e.g. de, en, es") }, + singleLine = true + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Usage Statistics + UsageStatsCard( + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(16.dp)) } } +private suspend fun validateOpenRouterApiKey(apiKey: String): Boolean { + return try { + Log.d("OpenRouterValidation", "=== Starting validation ===") + Log.d("OpenRouterValidation", "API Key length: ${apiKey.length}") + + val url = "https://openrouter.ai/api/v1/models?limit=1" + Log.d("OpenRouterValidation", "URL: $url") + + val request = okhttp3.Request.Builder() + .url(url) + .addHeader("Authorization", "Bearer $apiKey") + .build() + + Log.d("OpenRouterValidation", "Request built") + + val client = okhttp3.OkHttpClient.Builder() + .connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(10, java.util.concurrent.TimeUnit.SECONDS) + .build() + + Log.d("OpenRouterValidation", "Client created") + + Log.d("OpenRouterValidation", "Executing request...") + val response = client.newCall(request).execute() + Log.d("OpenRouterValidation", "Response received! Code: ${response.code}") + + val isValid = response.isSuccessful + Log.d("OpenRouterValidation", "Is successful: $isValid (${response.code})") + + if (!isValid) { + val errorBody = try { + response.body?.string() + } catch (e: Exception) { + "Could not read error body: ${e.message}" + } + Log.e("OpenRouterValidation", "API Error - Status ${response.code}: $errorBody") + } else { + Log.d("OpenRouterValidation", "✓ API Key is VALID") + } + + response.close() + isValid + } catch (e: Exception) { + Log.e("OpenRouterValidation", "EXCEPTION: ${e::class.simpleName} - ${e.message}") + Log.e("OpenRouterValidation", "Full stack: ${e.stackTraceToString()}") + false + } +} + +private suspend fun fetchOpenRouterModels(apiKey: String): List> { + return try { + Log.d("OpenRouterModels", "=== Starting model fetch ===") + + val url = "https://openrouter.ai/api/v1/models" + Log.d("OpenRouterModels", "URL: $url") + Log.d("OpenRouterModels", "API Key (first 20 chars): ${apiKey.take(20)}...") + + val request = okhttp3.Request.Builder() + .url(url) + .addHeader("Authorization", "Bearer $apiKey") + .build() + + val client = okhttp3.OkHttpClient.Builder() + .connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(30, java.util.concurrent.TimeUnit.SECONDS) + .build() + + Log.d("OpenRouterModels", "Sending request...") + val response = client.newCall(request).execute() + Log.d("OpenRouterModels", "Response code: ${response.code}") + + if (!response.isSuccessful) { + val errorBody = response.body?.string() + Log.e("OpenRouterModels", "API Error ${response.code}: $errorBody") + return emptyList() + } + + val responseBody = response.body?.string() + if (responseBody == null) { + Log.e("OpenRouterModels", "Response body is null") + return emptyList() + } + + Log.d("OpenRouterModels", "Response body length: ${responseBody.length}") + + val jsonObject = org.json.JSONObject(responseBody) + val models = jsonObject.optJSONArray("data") + + if (models == null) { + Log.e("OpenRouterModels", "No 'data' array in response. Keys: ${jsonObject.keys().asSequence().toList()}") + return emptyList() + } + + Log.d("OpenRouterModels", "Total models in response: ${models.length()}") + + val audioModels = mutableListOf>() + var skipped = 0 + var processedCount = 0 + + for (i in 0 until models.length()) { + try { + val model = models.getJSONObject(i) + val id = model.getString("id") + val name = model.optString("name", id) + + processedCount++ + + // Get architecture + val architecture = model.optJSONObject("architecture") + if (architecture == null) { + skipped++ + continue + } + + // Get input modalities + val inputModalities = architecture.optJSONArray("input_modalities") + if (inputModalities == null) { + skipped++ + continue + } + + // Check if "audio" is in input_modalities + var supportsAudio = false + for (j in 0 until inputModalities.length()) { + val modality = inputModalities.optString(j, "") + if (modality == "audio") { + supportsAudio = true + break + } + } + + if (supportsAudio) { + val pricing = model.optJSONObject("pricing") + val promptPrice = pricing?.optString("prompt", "0") ?: "0" + + // Format label with model name and pricing + val label = try { + val priceDouble = promptPrice.toDouble() + if (priceDouble > 0) { + // Convert to per-million tokens for readability + val pricePerMillion = priceDouble * 1_000_000 + "$name • \$${"%.2f".format(pricePerMillion)}/M tokens" + } else { + name + } + } catch (e: NumberFormatException) { + name + } + + Log.d("OpenRouterModels", "✓ Found audio model: $id") + audioModels.add(id to label) + } + } catch (e: Exception) { + Log.e("OpenRouterModels", "Error processing model at index $i: ${e.message}") + } + } + + Log.d("OpenRouterModels", "=== Model fetch complete ===") + Log.d("OpenRouterModels", "Processed: $processedCount, Skipped: $skipped, Found audio: ${audioModels.size}") + + // Sort models: Flash variants first, then others + audioModels.sortedWith(compareBy> { (id, _) -> + when { + id.contains("flash", ignoreCase = true) -> 0 // Flash models first + id.contains("2.0", ignoreCase = true) -> 1 // Then 2.0 models + else -> 2 // Everything else last + } + }.thenBy { it.first }) + } catch (e: Exception) { + Log.e("OpenRouterModels", "Fatal error during model fetch: ${e.message}") + Log.e("OpenRouterModels", "Stack trace: ${e.stackTraceToString()}") + emptyList() + } +} + +private enum class WhisperScreen { + Main, // Engine selection + Local, // Local Whisper settings + OpenAi, // OpenAI API settings + OpenRouter // OpenRouter API settings +} + diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/PhysicalKeyboardInputMethodService.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/PhysicalKeyboardInputMethodService.kt index 8c536e6a..8a78b0df 100644 --- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/PhysicalKeyboardInputMethodService.kt +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/PhysicalKeyboardInputMethodService.kt @@ -71,6 +71,8 @@ class PhysicalKeyboardInputMethodService : InputMethodService() { // Speech recognition using SpeechRecognizer (modern approach) private var speechRecognitionManager: SpeechRecognitionManager? = null private var whisperRecognitionManager: it.palsoftware.pastiera.inputmethod.whisper.WhisperRecognitionManager? = null + private var openAiRecognitionManager: it.palsoftware.pastiera.inputmethod.whisper.OpenAiWhisperRecognitionManager? = null + private var openRouterRecognitionManager: it.palsoftware.pastiera.inputmethod.whisper.OpenRouterWhisperRecognitionManager? = null private var isSpeechRecognitionActive: Boolean = false private var pendingSpeechRecognition: Boolean = false @@ -274,90 +276,176 @@ class PhysicalKeyboardInputMethodService : InputMethodService() { return } - // Check if Whisper is enabled - val useWhisper = it.palsoftware.pastiera.SettingsManager.getUseWhisper(this) + // Get selected speech recognition engine + val whisperMode = it.palsoftware.pastiera.SettingsManager.getWhisperMode(this) - if (useWhisper) { - // Use Whisper for offline recognition - if (whisperRecognitionManager == null) { - whisperRecognitionManager = it.palsoftware.pastiera.inputmethod.whisper.WhisperRecognitionManager( - context = this, - inputConnectionProvider = { currentInputConnection }, - onError = { errorMessage -> - Log.e(TAG, "Whisper recognition error: $errorMessage") - uiHandler.post { - Toast.makeText(this, errorMessage, Toast.LENGTH_SHORT).show() - } - }, - onRecognitionStateChanged = { isActive -> - isSpeechRecognitionActive = isActive - - if (isActive) { - modifierStateController.clearAltState() - modifierStateController.clearCtrlState() - } - - uiHandler.post { - candidatesBarController.setMicrophoneButtonActive(isActive) - candidatesBarController.showSpeechRecognitionHint(isActive) - if (!isActive) { - candidatesBarController.updateMicrophoneAudioLevel(-10f) - } else { - updateStatusBarText() + // Stop any ongoing recognition first + stopSpeechRecognition() + + when (whisperMode) { + "local" -> { + // Use Whisper for offline ONNX-based recognition + if (whisperRecognitionManager == null) { + whisperRecognitionManager = it.palsoftware.pastiera.inputmethod.whisper.WhisperRecognitionManager( + context = this, + inputConnectionProvider = { currentInputConnection }, + onError = { errorMessage -> + Log.e(TAG, "Whisper recognition error: $errorMessage") + uiHandler.post { + Toast.makeText(this, errorMessage, Toast.LENGTH_SHORT).show() + } + }, + onRecognitionStateChanged = { isActive -> + isSpeechRecognitionActive = isActive + + if (isActive) { + modifierStateController.clearAltState() + modifierStateController.clearCtrlState() + } + + uiHandler.post { + candidatesBarController.setMicrophoneButtonActive(isActive) + candidatesBarController.showSpeechRecognitionHint(isActive) + if (!isActive) { + candidatesBarController.updateMicrophoneAudioLevel(-10f) + } else { + updateStatusBarText() + } + } + }, + shouldDisableAutoCapitalize = { inputContextState.shouldDisableAutoCapitalize }, + onAudioLevelChanged = { rmsdB -> + uiHandler.post { + candidatesBarController.updateMicrophoneAudioLevel(rmsdB) } } - }, - shouldDisableAutoCapitalize = { inputContextState.shouldDisableAutoCapitalize }, - onAudioLevelChanged = { rmsdB -> - uiHandler.post { - candidatesBarController.updateMicrophoneAudioLevel(rmsdB) - } - } - ) + ) + } + whisperRecognitionManager?.startRecognition() } - whisperRecognitionManager?.startRecognition() - } else { - // Use Google SpeechRecognizer (existing implementation) - if (speechRecognitionManager == null) { - speechRecognitionManager = SpeechRecognitionManager( - context = this, - inputConnectionProvider = { currentInputConnection }, - onError = { errorMessage -> - Log.e(TAG, "Speech recognition error: $errorMessage") - }, - onRecognitionStateChanged = { isActive -> - // Update internal state - isSpeechRecognitionActive = isActive - - // Reset Alt and Ctrl modifiers when recognition starts - if (isActive) { - modifierStateController.clearAltState() - modifierStateController.clearCtrlState() + "openrouter" -> { + // Use OpenRouter Audio for cloud-based recognition with multiple models + if (openRouterRecognitionManager == null) { + openRouterRecognitionManager = it.palsoftware.pastiera.inputmethod.whisper.OpenRouterWhisperRecognitionManager( + context = this, + inputConnectionProvider = { currentInputConnection }, + onError = { errorMessage -> + Log.e(TAG, "OpenRouter Whisper recognition error: $errorMessage") + uiHandler.post { + Toast.makeText(this, errorMessage, Toast.LENGTH_SHORT).show() + } + }, + onRecognitionStateChanged = { isActive -> + isSpeechRecognitionActive = isActive + + if (isActive) { + modifierStateController.clearAltState() + modifierStateController.clearCtrlState() + } + + uiHandler.post { + candidatesBarController.setMicrophoneButtonActive(isActive) + candidatesBarController.showSpeechRecognitionHint(isActive) + if (!isActive) { + candidatesBarController.updateMicrophoneAudioLevel(-10f) + } else { + updateStatusBarText() + } + } + }, + shouldDisableAutoCapitalize = { inputContextState.shouldDisableAutoCapitalize }, + onAudioLevelChanged = { rmsdB -> + uiHandler.post { + candidatesBarController.updateMicrophoneAudioLevel(rmsdB) + } } - - // Update microphone button color and hint message based on recognition state - uiHandler.post { - candidatesBarController.setMicrophoneButtonActive(isActive) - candidatesBarController.showSpeechRecognitionHint(isActive) - // Reset audio level when recognition stops - if (!isActive) { - candidatesBarController.updateMicrophoneAudioLevel(-10f) - } else { - // Update status bar after resetting modifiers - updateStatusBarText() + ) + } + openRouterRecognitionManager?.startRecognition() + } + "api" -> { + // Use OpenAI Whisper API for cloud-based recognition + if (openAiRecognitionManager == null) { + openAiRecognitionManager = it.palsoftware.pastiera.inputmethod.whisper.OpenAiWhisperRecognitionManager( + context = this, + inputConnectionProvider = { currentInputConnection }, + onError = { errorMessage -> + Log.e(TAG, "OpenAI Whisper recognition error: $errorMessage") + uiHandler.post { + Toast.makeText(this, errorMessage, Toast.LENGTH_SHORT).show() + } + }, + onRecognitionStateChanged = { isActive -> + isSpeechRecognitionActive = isActive + + if (isActive) { + modifierStateController.clearAltState() + modifierStateController.clearCtrlState() + } + + uiHandler.post { + candidatesBarController.setMicrophoneButtonActive(isActive) + candidatesBarController.showSpeechRecognitionHint(isActive) + if (!isActive) { + candidatesBarController.updateMicrophoneAudioLevel(-10f) + } else { + updateStatusBarText() + } + } + }, + shouldDisableAutoCapitalize = { inputContextState.shouldDisableAutoCapitalize }, + onAudioLevelChanged = { rmsdB -> + uiHandler.post { + candidatesBarController.updateMicrophoneAudioLevel(rmsdB) } } - }, - shouldDisableAutoCapitalize = { inputContextState.shouldDisableAutoCapitalize }, - onAudioLevelChanged = { rmsdB -> - // Update microphone button based on audio level - uiHandler.post { - candidatesBarController.updateMicrophoneAudioLevel(rmsdB) + ) + } + openAiRecognitionManager?.startRecognition() + } + else -> { + // Default: Use Google SpeechRecognizer (stock Android API) + if (speechRecognitionManager == null) { + speechRecognitionManager = SpeechRecognitionManager( + context = this, + inputConnectionProvider = { currentInputConnection }, + onError = { errorMessage -> + Log.e(TAG, "Speech recognition error: $errorMessage") + }, + onRecognitionStateChanged = { isActive -> + // Update internal state + isSpeechRecognitionActive = isActive + + // Reset Alt and Ctrl modifiers when recognition starts + if (isActive) { + modifierStateController.clearAltState() + modifierStateController.clearCtrlState() + } + + // Update microphone button color and hint message based on recognition state + uiHandler.post { + candidatesBarController.setMicrophoneButtonActive(isActive) + candidatesBarController.showSpeechRecognitionHint(isActive) + // Reset audio level when recognition stops + if (!isActive) { + candidatesBarController.updateMicrophoneAudioLevel(-10f) + } else { + // Update status bar after resetting modifiers + updateStatusBarText() + } + } + }, + shouldDisableAutoCapitalize = { inputContextState.shouldDisableAutoCapitalize }, + onAudioLevelChanged = { rmsdB -> + // Update microphone button based on audio level + uiHandler.post { + candidatesBarController.updateMicrophoneAudioLevel(rmsdB) + } } - } - ) + ) + } + speechRecognitionManager?.startRecognition() } - speechRecognitionManager?.startRecognition() } } @@ -367,6 +455,8 @@ class PhysicalKeyboardInputMethodService : InputMethodService() { private fun stopSpeechRecognition() { speechRecognitionManager?.stopRecognition() whisperRecognitionManager?.stopRecognition() + openAiRecognitionManager?.stopRecognition() + openRouterRecognitionManager?.stopRecognition() } private fun getSuggestionSettings(): SuggestionSettings { @@ -1167,9 +1257,15 @@ class PhysicalKeyboardInputMethodService : InputMethodService() { prefs.unregisterOnSharedPreferenceChangeListener(it) } - // Cleanup SpeechRecognitionManager + // Cleanup Speech Recognition Managers speechRecognitionManager?.destroy() speechRecognitionManager = null + whisperRecognitionManager?.destroy() + whisperRecognitionManager = null + openAiRecognitionManager?.destroy() + openAiRecognitionManager = null + openRouterRecognitionManager?.destroy() + openRouterRecognitionManager = null // Cleanup ClipboardHistoryManager clipboardHistoryManager.setHistoryChangeListener(null) diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/AudioDebugHelper.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/AudioDebugHelper.kt new file mode 100644 index 00000000..13902553 --- /dev/null +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/AudioDebugHelper.kt @@ -0,0 +1,103 @@ +package it.palsoftware.pastiera.inputmethod.whisper + +import android.content.Context +import android.util.Log +import java.io.File +import java.text.SimpleDateFormat +import java.util.* + +/** + * Helper for debugging audio capture issues. + * Saves recorded audio to external cache directory for inspection. + */ +object AudioDebugHelper { + private const val TAG = "AudioDebugHelper" + private const val DEBUG_AUDIO_DIR = "whisper_debug_audio" + + /** + * Saves current audio buffer to a WAV file for debugging + * Returns the path to the saved file, or null if failed + */ + fun saveAudioDebug( + context: Context, + description: String = "" + ): String? { + return try { + val audioBytes = WhisperRecordBuffer.getOutputBuffer() ?: run { + Log.w(TAG, "No audio buffer to save") + return null + } + + if (audioBytes.isEmpty()) { + Log.w(TAG, "Audio buffer is empty") + return null + } + + // Create debug directory + val debugDir = File(context.cacheDir, DEBUG_AUDIO_DIR) + debugDir.mkdirs() + + // Create timestamped filename + val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + val filename = "audio_${timestamp}${if (description.isNotEmpty()) "_$description" else ""}.wav" + val outputFile = File(debugDir, filename) + + // Write WAV file + val success = WavAudioWriter.writeWavFile(audioBytes, outputFile) + + if (success) { + Log.i(TAG, "Audio saved to: ${outputFile.absolutePath}") + Log.i(TAG, "File size: ${outputFile.length()} bytes") + WavAudioWriter.logAudioStats(audioBytes) + outputFile.absolutePath + } else { + Log.e(TAG, "Failed to write audio file") + null + } + } catch (e: Exception) { + Log.e(TAG, "Error saving audio debug", e) + null + } + } + + /** + * Lists all saved debug audio files + */ + fun listDebugAudioFiles(context: Context): List { + return try { + val debugDir = File(context.cacheDir, DEBUG_AUDIO_DIR) + if (!debugDir.exists()) { + return emptyList() + } + debugDir.listFiles()?.filter { it.extension == "wav" }?.sortedByDescending { it.lastModified() } + ?: emptyList() + } catch (e: Exception) { + Log.e(TAG, "Error listing debug audio files", e) + emptyList() + } + } + + /** + * Clears all debug audio files + */ + fun clearDebugAudioFiles(context: Context) { + try { + val debugDir = File(context.cacheDir, DEBUG_AUDIO_DIR) + if (debugDir.exists()) { + debugDir.deleteRecursively() + Log.i(TAG, "Debug audio files cleared") + } + } catch (e: Exception) { + Log.e(TAG, "Error clearing debug audio files", e) + } + } + + /** + * Gets the directory path for debug audio files + */ + fun getDebugAudioDir(context: Context): File { + return File(context.cacheDir, DEBUG_AUDIO_DIR) + } +} + + diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/OpenAiWhisperClient.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/OpenAiWhisperClient.kt new file mode 100644 index 00000000..8566a372 --- /dev/null +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/OpenAiWhisperClient.kt @@ -0,0 +1,161 @@ +package it.palsoftware.pastiera.inputmethod.whisper + +import android.util.Log +import okhttp3.* +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.asRequestBody +import org.json.JSONObject +import java.io.File +import java.util.concurrent.TimeUnit + +/** + * OpenAI Whisper API Client for remote speech-to-text transcription. + * Supports GPT-4o transcribe, GPT-4o mini transcribe, and Whisper-1 models. + */ +class OpenAiWhisperClient( + private val apiKey: String +) { + companion object { + private const val TAG = "OpenAiWhisperClient" + private const val BASE_URL = "https://api.openai.com/v1" + private const val TRANSCRIPTIONS_ENDPOINT = "$BASE_URL/audio/transcriptions" + private const val REQUEST_TIMEOUT_SECONDS = 120L + } + + data class TranscriptionResult( + val text: String, + val language: String? = null, + val usage: TokenUsage? = null + ) + + data class TokenUsage( + val inputTokens: Int, + val outputTokens: Int, + val totalTokens: Int + ) + + enum class Model(val id: String) { + GPT_4O_TRANSCRIBE("gpt-4o-transcribe"), + GPT_4O_MINI_TRANSCRIBE("gpt-4o-mini-transcribe"), + WHISPER_1("whisper-1") + } + + private val httpClient: OkHttpClient = OkHttpClient.Builder() + .connectTimeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .writeTimeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .build() + + /** + * Transcribes audio file using OpenAI Whisper API + */ + suspend fun transcribeAudio( + audioFile: File, + model: String = Model.GPT_4O_TRANSCRIBE.id, + language: String? = null, + prompt: String? = null, + temperature: Float = 0f + ): Result { + return try { + if (!audioFile.exists()) { + return Result.failure(Exception("Audio file not found: ${audioFile.absolutePath}")) + } + + val requestBody = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart( + "file", + audioFile.name, + audioFile.asRequestBody("audio/wav; charset=utf-8".toMediaType()) + ) + .addFormDataPart("model", model) + .addFormDataPart("response_format", "json") + + // Add optional parameters + if (language != null && language.isNotEmpty()) { + requestBody.addFormDataPart("language", language) + } + if (prompt != null && prompt.isNotEmpty()) { + requestBody.addFormDataPart("prompt", prompt) + } + if (temperature > 0) { + requestBody.addFormDataPart("temperature", temperature.toString()) + } + + val request = Request.Builder() + .url(TRANSCRIPTIONS_ENDPOINT) + .header("Authorization", "Bearer $apiKey") + .post(requestBody.build()) + .build() + + httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + val errorBody = response.body?.string() ?: "Unknown error" + Log.e(TAG, "API error: ${response.code} - $errorBody") + return Result.failure(Exception("API error: ${response.code} - $errorBody")) + } + + val responseBody = response.body?.string() + ?: return Result.failure(Exception("Empty response body")) + + val jsonResponse = JSONObject(responseBody) + val text = jsonResponse.getString("text") + + // Parse optional fields + val language = if (jsonResponse.has("language")) { + jsonResponse.getString("language") + } else null + + val usage = if (jsonResponse.has("usage")) { + val usageObj = jsonResponse.getJSONObject("usage") + TokenUsage( + inputTokens = usageObj.optInt("input_tokens", 0), + outputTokens = usageObj.optInt("output_tokens", 0), + totalTokens = usageObj.optInt("total_tokens", 0) + ) + } else null + + Log.d(TAG, "Transcription successful: '$text' (language: $language)") + Result.success(TranscriptionResult(text, language, usage)) + } + } catch (e: Exception) { + Log.e(TAG, "Transcription failed", e) + Result.failure(e) + } + } + + /** + * Validates the API key by making a minimal request + */ + suspend fun validateApiKey(): Result { + return try { + if (apiKey.isEmpty()) { + return Result.failure(Exception("API key is empty")) + } + + // Create a minimal test request + val testBody = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("model", Model.WHISPER_1.id) + .addFormDataPart("response_format", "json") + .build() + + val request = Request.Builder() + .url(TRANSCRIPTIONS_ENDPOINT) + .header("Authorization", "Bearer $apiKey") + .post(testBody) + .build() + + httpClient.newCall(request).execute().use { response -> + // 400 Bad Request is expected (no file), but 401 means auth failed + val isValid = response.code != 401 + Log.d(TAG, "API key validation: ${if (isValid) "valid" else "invalid"} (${response.code})") + Result.success(isValid) + } + } catch (e: Exception) { + Log.e(TAG, "API key validation failed", e) + Result.failure(e) + } + } +} + diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/OpenAiWhisperRecognitionManager.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/OpenAiWhisperRecognitionManager.kt new file mode 100644 index 00000000..e206c2e5 --- /dev/null +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/OpenAiWhisperRecognitionManager.kt @@ -0,0 +1,378 @@ +package it.palsoftware.pastiera.inputmethod.whisper + +import android.Manifest +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.view.inputmethod.InputConnection +import android.widget.Toast +import androidx.core.content.ContextCompat +import it.palsoftware.pastiera.R +import it.palsoftware.pastiera.SettingsManager +import it.palsoftware.pastiera.inputmethod.AutoCapitalizeHelper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.util.Locale +import it.palsoftware.pastiera.inputmethod.whisper.UsageStatsManager +import it.palsoftware.pastiera.inputmethod.whisper.TranscriptionStats + +/** + * Manages OpenAI Whisper API-based speech recognition. + * Provides high-quality transcription via OpenAI's cloud infrastructure. + * Provides an API similar to WhisperRecognitionManager for seamless integration. + */ +class OpenAiWhisperRecognitionManager( + private val context: Context, + private val inputConnectionProvider: () -> InputConnection?, + private val onError: ((String) -> Unit)? = null, + private val onRecognitionStateChanged: ((Boolean) -> Unit)? = null, + private val shouldDisableAutoCapitalize: () -> Boolean = { false }, + private val onAudioLevelChanged: ((Float) -> Unit)? = null +) { + companion object { + private const val TAG = "OpenAiWhisperRecognitionMgr" + private const val TEMP_AUDIO_FILE = "openai_temp_audio.wav" + } + + private var openAiClient: OpenAiWhisperClient? = null + private var whisperRecorder: WhisperRecorder? = null + private var isRecognizing = false + private var isProcessing = false + + /** + * Checks if OpenAI API is configured properly + */ + fun isAvailable(): Boolean { + val apiKey = SettingsManager.getOpenAiApiKey(context) + return apiKey.isNotEmpty() + } + + /** + * Formats text according to standard auto-capitalization rules + */ + private fun formatTextWithAutoCapitalization(text: String): String { + if (text.isEmpty()) return text + + val inputConnection = inputConnectionProvider() ?: return text + + if (shouldDisableAutoCapitalize()) { + return text + } + + var formatted = text + + // Capitalize first letter if needed + val shouldCapitalizeFirst = AutoCapitalizeHelper.shouldAutoCapitalizeAtCursor( + context = context, + inputConnection = inputConnection, + shouldDisableAutoCapitalize = shouldDisableAutoCapitalize() + ) && SettingsManager.getAutoCapitalizeFirstLetter(context) + + if (shouldCapitalizeFirst && formatted.isNotEmpty()) { + formatted = formatted.replaceFirstChar { + if (it.isLowerCase()) it.titlecase(Locale.getDefault()) + else it.toString() + } + } + + // Capitalize after sentence-ending punctuation + if (SettingsManager.getAutoCapitalizeAfterPeriod(context)) { + formatted = formatted.replace(Regex("([.!?]\\s+)([a-z])")) { matchResult -> + matchResult.groupValues[1] + matchResult.groupValues[2].uppercase() + } + } + + return formatted + } + + /** + * Inserts recognized text into the input connection + */ + private fun insertRecognizedText(text: String) { + Handler(Looper.getMainLooper()).post { + val inputConnection = inputConnectionProvider() ?: return@post + + try { + var textToCommit = formatTextWithAutoCapitalization(text) + + // Add spacing rules + val textBeforeCursor = inputConnection.getTextBeforeCursor(10, 0) + if (textBeforeCursor != null && textBeforeCursor.isNotEmpty()) { + val lastChar = textBeforeCursor.last() + if (lastChar.isLetter()) { + textToCommit = " $textToCommit" + } + } + + // Always add space at the end + textToCommit += " " + + inputConnection.commitText(textToCommit, 1) + Log.d(TAG, "Inserted recognized text: '$textToCommit'") + } catch (e: Exception) { + Log.e(TAG, "Error inserting text", e) + } + } + } + + /** + * Initializes the OpenAI client + */ + private fun ensureOpenAiClient(): Boolean { + if (openAiClient != null) { + return true + } + + try { + val apiKey = SettingsManager.getOpenAiApiKey(context) + if (apiKey.isEmpty()) { + Log.e(TAG, "OpenAI API key not configured") + onError?.invoke(context.getString(R.string.openai_error_api_key_missing)) + return false + } + + openAiClient = OpenAiWhisperClient(apiKey) + Log.d(TAG, "OpenAI client initialized") + return true + } catch (e: Exception) { + Log.e(TAG, "Error initializing OpenAI client", e) + onError?.invoke(context.getString(R.string.openai_error_initialization)) + return false + } + } + + /** + * Starts OpenAI Whisper speech recognition + */ + fun startRecognition() { + // Check RECORD_AUDIO permission + if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) + != android.content.pm.PackageManager.PERMISSION_GRANTED) { + Log.e(TAG, "RECORD_AUDIO permission not granted") + onError?.invoke(context.getString(R.string.speech_recognition_error_permission)) + return + } + + if (isRecognizing) { + Log.w(TAG, "Already recognizing") + return + } + + // Ensure client is initialized + if (!ensureOpenAiClient()) { + return + } + + try { + isRecognizing = true + onRecognitionStateChanged?.invoke(true) + + // Initialize recorder + whisperRecorder = WhisperRecorder(context).apply { + setListener(object : WhisperRecorder.RecorderListener { + override fun onRecordingStarted() { + Log.d(TAG, "Recording started") + } + + override fun onRecording() { + // Update audio level feedback + onAudioLevelChanged?.invoke(10f) + } + + override fun onRecordingDone() { + Log.d(TAG, "Recording done, starting transcription via OpenAI API") + onAudioLevelChanged?.invoke(-20f) + startTranscription() + } + + override fun onRecordingError(error: String) { + Log.e(TAG, "Recording error: $error") + isRecognizing = false + onRecognitionStateChanged?.invoke(false) + onError?.invoke(error) + } + }) + + initVad() + start() + } + + Log.d(TAG, "OpenAI Whisper recognition started") + } catch (e: Exception) { + Log.e(TAG, "Error starting OpenAI Whisper recognition", e) + isRecognizing = false + onRecognitionStateChanged?.invoke(false) + onError?.invoke(context.getString(R.string.openai_error_generic)) + } + } + + /** + * Starts transcription after recording is complete + */ + private fun startTranscription() { + if (isProcessing) { + Log.w(TAG, "Already processing") + return + } + + isProcessing = true + + // No toast - auto-stop is more intuitive + Log.d(TAG, "Processing audio via OpenAI API...") + + // Run transcription in background + CoroutineScope(Dispatchers.IO).launch { + try { + // Get the recorded audio buffer + val audioBuffer = WhisperRecordBuffer.getOutputBuffer() + if (audioBuffer == null || audioBuffer.isEmpty()) { + Log.w(TAG, "No audio buffer") + withContext(Dispatchers.Main) { + isProcessing = false + isRecognizing = false + onRecognitionStateChanged?.invoke(false) + onError?.invoke(context.getString(R.string.speech_recognition_error_no_match)) + } + return@launch + } + + // Save audio to temporary file with proper WAV format + val tempAudioFile = File(context.cacheDir, TEMP_AUDIO_FILE) + WavAudioWriter.writeWavFile(audioBuffer, tempAudioFile) + + // Log audio statistics for debugging + WavAudioWriter.logAudioStats(audioBuffer) + + // Get API parameters from settings + val model = SettingsManager.getOpenAiModel(context) + var language = SettingsManager.getOpenAiLanguage(context).takeIf { it.isNotEmpty() } ?: "" + + // Fall back to system language if not set + if (language.isEmpty()) { + language = java.util.Locale.getDefault().language + Log.d(TAG, "Using system language: $language") + } + + val prompt = SettingsManager.getOpenAiPrompt(context).takeIf { it.isNotEmpty() } + val temperature = SettingsManager.getOpenAiTemperature(context) + + Log.d(TAG, "Calling OpenAI API with model=$model, language=$language, prompt=${prompt?.take(50)}...") + Log.d(TAG, "Audio file: ${tempAudioFile.absolutePath} (${tempAudioFile.length()} bytes)") + + // Call OpenAI API + val result = openAiClient?.transcribeAudio( + tempAudioFile, + model, + language, + prompt, + temperature + ) + + withContext(Dispatchers.Main) { + isProcessing = false + isRecognizing = false + onRecognitionStateChanged?.invoke(false) + + if (result?.isSuccess == true) { + val transcriptionResult = result.getOrNull() + if (transcriptionResult != null && transcriptionResult.text.isNotEmpty()) { + Log.d(TAG, "Transcription successful: '${transcriptionResult.text}'") + + // Save usage statistics + try { + val statsManager = UsageStatsManager(context) + // Count words by splitting on whitespace + val wordCount = if (transcriptionResult.text.isEmpty()) { + 0 + } else { + transcriptionResult.text.split("\\s+".toRegex()).filter { it.isNotEmpty() }.size + } + // Audio duration: file size / (16000 Hz * 2 bytes per sample) + val recordingDurationMs = tempAudioFile.length() * 1000L / 32000L + + val stat = TranscriptionStats( + id = java.util.UUID.randomUUID().toString(), + engine = "openai", + model = SettingsManager.getOpenAiModel(context), + timestamp = java.time.LocalDateTime.now().toString(), + audioLengthMs = recordingDurationMs, + textLength = transcriptionResult.text.length, + wordCount = wordCount, + costUsd = 0.0, // TODO: Fetch from OpenAI Usage API + successFul = true + ) + statsManager.addStat(stat) + + // Also save per-model breakdown + val prefs = context.getSharedPreferences("usage_stats", android.content.Context.MODE_PRIVATE) + val modelKey = "model_${stat.model}".replace("/", "_").replace("-", "_") + val currentCount = prefs.getLong("${modelKey}_count", 0L) + val currentWords = prefs.getLong("${modelKey}_words", 0L) + val currentCost = prefs.getFloat("${modelKey}_cost", 0f) + + prefs.edit().apply { + putLong("${modelKey}_count", currentCount + 1) + putLong("${modelKey}_words", currentWords + wordCount) + putFloat("${modelKey}_cost", (currentCost + stat.costUsd).toFloat()) + apply() + } + + Log.d(TAG, "Stats saved - Model: ${stat.model}, Words: $wordCount, WPM: ${"%.1f".format(stat.getWordsPerMinute())}, Duration: ${recordingDurationMs}ms") + } catch (e: Exception) { + Log.e(TAG, "Error saving stats: ${e.message}") + } + + insertRecognizedText(transcriptionResult.text) + } else { + Log.w(TAG, "No transcription result") + onError?.invoke(context.getString(R.string.speech_recognition_error_no_match)) + } + } else { + val errorMsg = result?.exceptionOrNull()?.message ?: "Unknown error" + Log.e(TAG, "Transcription failed: $errorMsg") + onError?.invoke(context.getString(R.string.openai_error_transcription, errorMsg)) + } + + // Clean up temporary file + tempAudioFile.delete() + } + } catch (e: Exception) { + Log.e(TAG, "Error during transcription", e) + withContext(Dispatchers.Main) { + isProcessing = false + isRecognizing = false + onRecognitionStateChanged?.invoke(false) + onError?.invoke(context.getString(R.string.openai_error_generic)) + } + } + } + } + + /** + * Stops recognition if active + */ + fun stopRecognition() { + whisperRecorder?.stop() + isRecognizing = false + onRecognitionStateChanged?.invoke(false) + Log.d(TAG, "OpenAI Whisper recognition stopped") + } + + /** + * Releases all resources + */ + fun destroy() { + whisperRecorder?.release() + whisperRecorder = null + openAiClient = null + WhisperRecordBuffer.clear() + isRecognizing = false + isProcessing = false + Log.d(TAG, "OpenAI Whisper recognition manager destroyed") + } +} + diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/OpenRouterWhisperClient.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/OpenRouterWhisperClient.kt new file mode 100644 index 00000000..c36359d1 --- /dev/null +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/OpenRouterWhisperClient.kt @@ -0,0 +1,318 @@ +package it.palsoftware.pastiera.inputmethod.whisper + +import android.util.Log +import org.json.JSONArray +import org.json.JSONObject +import java.io.BufferedReader +import java.io.InputStreamReader +import java.net.HttpURLConnection +import java.net.URL +import java.util.Base64 +import it.palsoftware.pastiera.inputmethod.whisper.UsageInfo + +/** + * Client for OpenRouter Audio Speech Recognition API. + * Handles audio transcription via OpenRouter's multimodal models. + */ +class OpenRouterWhisperClient(private val apiKey: String) { + + private val TAG = "OpenRouterWhisperClient" + private val API_BASE = "https://openrouter.ai/api/v1" + private val TIMEOUT_MS = 60000 // 60 seconds for large uploads + + /** + * Transcribes audio data using OpenRouter API + */ + fun transcribeAudio( + audioData: ByteArray, + model: String = "google/gemini-2.5-flash", + language: String? = null + ): Result { + return try { + Log.d(TAG, "Starting transcription with model: $model") + Log.d(TAG, "Raw audio data size: ${audioData.size} bytes") + + // Convert raw PCM16 to WAV format + val wavAudioData = convertPcmToWav(audioData) + Log.d(TAG, "WAV audio data size: ${wavAudioData.size} bytes") + + // Encode audio to base64 + val base64Audio = Base64.getEncoder().encodeToString(wavAudioData) + Log.d(TAG, "Audio encoded to base64 (${base64Audio.length} chars)") + + // Audio format is now properly WAV + val audioFormat = "wav" + + // Build request payload + val prompt = buildPrompt(language) + val payload = buildPayload(base64Audio, audioFormat, model, prompt) + + Log.d(TAG, "Sending request to OpenRouter API...") + val response = sendRequest(payload) + + // Parse response + val (transcribedText, usage) = parseResponse(response) + + Log.d(TAG, "Transcription successful: $transcribedText") + Log.d(TAG, "Usage: $usage") + + Result.success(WhisperResult( + text = transcribedText, + language = language ?: "auto", + confidence = 0.95f, + model = model, + promptTokens = usage?.promptTokens ?: 0, + completionTokens = usage?.completionTokens ?: 0, + costUsd = usage?.costUsd ?: 0.0 + )) + } catch (e: Exception) { + Log.e(TAG, "Transcription error: ${e.message}", e) + Result.failure(e) + } + } + + /** + * Converts raw PCM16 audio data to WAV format (16-bit, 16kHz mono) + */ + private fun convertPcmToWav(pcmData: ByteArray): ByteArray { + val sampleRate = 16000 // 16kHz + val numChannels = 1 // Mono + val bitDepth = 16 // 16-bit + val byteRate = sampleRate * numChannels * (bitDepth / 8) + val blockAlign = numChannels * (bitDepth / 8) + + val wavData = ByteArray(44 + pcmData.size) + + // WAV header (44 bytes) + var offset = 0 + + // "RIFF" chunk descriptor + wavData[offset++] = 'R'.code.toByte() + wavData[offset++] = 'I'.code.toByte() + wavData[offset++] = 'F'.code.toByte() + wavData[offset++] = 'F'.code.toByte() + + // File size - 8 (little-endian) + val fileSize = 36 + pcmData.size + wavData[offset++] = (fileSize and 0xFF).toByte() + wavData[offset++] = ((fileSize shr 8) and 0xFF).toByte() + wavData[offset++] = ((fileSize shr 16) and 0xFF).toByte() + wavData[offset++] = ((fileSize shr 24) and 0xFF).toByte() + + // "WAVE" format + wavData[offset++] = 'W'.code.toByte() + wavData[offset++] = 'A'.code.toByte() + wavData[offset++] = 'V'.code.toByte() + wavData[offset++] = 'E'.code.toByte() + + // "fmt " subchunk + wavData[offset++] = 'f'.code.toByte() + wavData[offset++] = 'm'.code.toByte() + wavData[offset++] = 't'.code.toByte() + wavData[offset++] = ' '.code.toByte() + + // Subchunk1Size (16 for PCM) + wavData[offset++] = 16 + wavData[offset++] = 0 + wavData[offset++] = 0 + wavData[offset++] = 0 + + // Audio format (1 = PCM) + wavData[offset++] = 1 + wavData[offset++] = 0 + + // Number of channels + wavData[offset++] = numChannels.toByte() + wavData[offset++] = 0 + + // Sample rate (little-endian) + wavData[offset++] = (sampleRate and 0xFF).toByte() + wavData[offset++] = ((sampleRate shr 8) and 0xFF).toByte() + wavData[offset++] = ((sampleRate shr 16) and 0xFF).toByte() + wavData[offset++] = ((sampleRate shr 24) and 0xFF).toByte() + + // Byte rate (little-endian) + wavData[offset++] = (byteRate and 0xFF).toByte() + wavData[offset++] = ((byteRate shr 8) and 0xFF).toByte() + wavData[offset++] = ((byteRate shr 16) and 0xFF).toByte() + wavData[offset++] = ((byteRate shr 24) and 0xFF).toByte() + + // Block align + wavData[offset++] = blockAlign.toByte() + wavData[offset++] = 0 + + // Bits per sample + wavData[offset++] = bitDepth.toByte() + wavData[offset++] = 0 + + // "data" subchunk + wavData[offset++] = 'd'.code.toByte() + wavData[offset++] = 'a'.code.toByte() + wavData[offset++] = 't'.code.toByte() + wavData[offset++] = 'a'.code.toByte() + + // Subchunk2Size (PCM data size) + wavData[offset++] = (pcmData.size and 0xFF).toByte() + wavData[offset++] = ((pcmData.size shr 8) and 0xFF).toByte() + wavData[offset++] = ((pcmData.size shr 16) and 0xFF).toByte() + wavData[offset++] = ((pcmData.size shr 24) and 0xFF).toByte() + + // Copy PCM data + System.arraycopy(pcmData, 0, wavData, 44, pcmData.size) + + Log.d(TAG, "WAV header created: channels=$numChannels, sample_rate=$sampleRate, bits=$bitDepth") + + return wavData + } + + /** + * Builds the transcription prompt + */ + private fun buildPrompt(language: String?): String { + return if (language != null && language != "auto") { + "Please transcribe this audio file. The audio is in $language. Return only the transcribed text." + } else { + "Please transcribe this audio file. Return only the transcribed text." + } + } + + /** + * Builds the API request payload + */ + private fun buildPayload( + base64Audio: String, + format: String, + model: String, + prompt: String + ): JSONObject { + val payload = JSONObject() + + payload.put("model", model) + + // Build messages + val messages = JSONArray() + val message = JSONObject() + message.put("role", "user") + + val content = JSONArray() + + // Text content + val textContent = JSONObject() + textContent.put("type", "text") + textContent.put("text", prompt) + content.put(textContent) + + // Audio content + val audioContent = JSONObject() + audioContent.put("type", "input_audio") + + val inputAudio = JSONObject() + inputAudio.put("data", base64Audio) + inputAudio.put("format", format) + audioContent.put("input_audio", inputAudio) + + content.put(audioContent) + + message.put("content", content) + messages.put(message) + + payload.put("messages", messages) + + // Settings + payload.put("stream", false) + + return payload + } + + /** + * Sends the request to OpenRouter API + */ + private fun sendRequest(payload: JSONObject): String { + val url = URL("$API_BASE/chat/completions") + val connection = url.openConnection() as HttpURLConnection + + try { + connection.requestMethod = "POST" + connection.setRequestProperty("Authorization", "Bearer $apiKey") + connection.setRequestProperty("Content-Type", "application/json") + connection.setRequestProperty("HTTP-Referer", "https://pastiera.app") + connection.connectTimeout = TIMEOUT_MS + connection.readTimeout = TIMEOUT_MS + + // Send payload + connection.doOutput = true + val outputStream = connection.outputStream + outputStream.write(payload.toString().toByteArray()) + outputStream.flush() + outputStream.close() + + // Read response + val statusCode = connection.responseCode + Log.d(TAG, "API response status: $statusCode") + + if (statusCode != HttpURLConnection.HTTP_OK) { + val errorStream = connection.errorStream + val errorText = errorStream?.bufferedReader().use { it?.readText() ?: "" } + Log.e(TAG, "API Error ($statusCode): $errorText") + throw Exception("OpenRouter API error: $statusCode - $errorText") + } + + val inputStream = connection.inputStream + val response = inputStream.bufferedReader().use { it.readText() } + Log.d(TAG, "API response received (${response.length} chars)") + + return response + } finally { + connection.disconnect() + } + } + + /** + * Parses the API response + */ + private fun parseResponse(response: String): Pair { + try { + val jsonResponse = JSONObject(response) + + // Check for errors + if (jsonResponse.has("error")) { + val error = jsonResponse.getJSONObject("error") + val errorMsg = error.getString("message") + throw Exception("API Error: $errorMsg") + } + + // Extract text from response + val choices = jsonResponse.getJSONArray("choices") + if (choices.length() == 0) { + throw Exception("No choices in response") + } + + val firstChoice = choices.getJSONObject(0) + val message = firstChoice.getJSONObject("message") + val content = message.getString("content") + + Log.d(TAG, "Extracted text: $content") + + // Extract usage information if available + var usage: UsageInfo? = null + if (jsonResponse.has("usage")) { + val usageObj = jsonResponse.getJSONObject("usage") + val promptTokens = usageObj.optInt("prompt_tokens", 0) + val completionTokens = usageObj.optInt("completion_tokens", 0) + Log.d(TAG, "Tokens - Prompt: $promptTokens, Completion: $completionTokens") + + usage = UsageInfo( + promptTokens = promptTokens, + completionTokens = completionTokens, + costUsd = 0.0 // Will be set by the manager if needed + ) + } + + return Pair(content.trim(), usage) + } catch (e: Exception) { + Log.e(TAG, "Error parsing response: ${e.message}") + throw e + } + } +} + diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/OpenRouterWhisperRecognitionManager.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/OpenRouterWhisperRecognitionManager.kt new file mode 100644 index 00000000..f362e5c2 --- /dev/null +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/OpenRouterWhisperRecognitionManager.kt @@ -0,0 +1,372 @@ +package it.palsoftware.pastiera.inputmethod.whisper + +import android.Manifest +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.view.inputmethod.InputConnection +import android.widget.Toast +import androidx.core.content.ContextCompat +import it.palsoftware.pastiera.R +import it.palsoftware.pastiera.SettingsManager +import it.palsoftware.pastiera.inputmethod.AutoCapitalizeHelper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.util.Locale +import it.palsoftware.pastiera.inputmethod.whisper.UsageStatsManager +import it.palsoftware.pastiera.inputmethod.whisper.TranscriptionStats + +/** + * Manages OpenRouter Audio Speech Recognition. + * Supports multiple audio models from OpenRouter API. + * Provides seamless integration with model selection and pricing display. + */ +class OpenRouterWhisperRecognitionManager( + private val context: Context, + private val inputConnectionProvider: () -> InputConnection?, + private val onError: ((String) -> Unit)? = null, + private val onRecognitionStateChanged: ((Boolean) -> Unit)? = null, + private val shouldDisableAutoCapitalize: () -> Boolean = { false }, + private val onAudioLevelChanged: ((Float) -> Unit)? = null +) { + companion object { + private const val TAG = "OpenRouterWhisperMgr" + } + + private var openRouterClient: OpenRouterWhisperClient? = null + private var whisperRecorder: WhisperRecorder? = null + private var isRecognizing = false + private var isProcessing = false + + /** + * Checks if OpenRouter API is configured properly + */ + fun isAvailable(): Boolean { + val apiKey = SettingsManager.getOpenRouterApiKey(context) + return apiKey.isNotEmpty() + } + + /** + * Formats text according to standard auto-capitalization rules + */ + private fun formatTextWithAutoCapitalization(text: String): String { + if (text.isEmpty()) return text + + val inputConnection = inputConnectionProvider() ?: return text + + if (shouldDisableAutoCapitalize()) { + return text + } + + var formatted = text + + // Capitalize first letter if needed + val shouldCapitalizeFirst = AutoCapitalizeHelper.shouldAutoCapitalizeAtCursor( + context = context, + inputConnection = inputConnection, + shouldDisableAutoCapitalize = shouldDisableAutoCapitalize() + ) && SettingsManager.getAutoCapitalizeFirstLetter(context) + + if (shouldCapitalizeFirst && formatted.isNotEmpty()) { + formatted = formatted.replaceFirstChar { + if (it.isLowerCase()) it.titlecase(Locale.getDefault()) + else it.toString() + } + } + + // Capitalize after sentence-ending punctuation + if (SettingsManager.getAutoCapitalizeAfterPeriod(context)) { + formatted = formatted.replace(Regex("([.!?]\\s+)([a-z])")) { matchResult -> + matchResult.groupValues[1] + matchResult.groupValues[2].uppercase() + } + } + + return formatted + } + + /** + * Inserts recognized text into the input connection + */ + private fun insertRecognizedText(text: String) { + Handler(Looper.getMainLooper()).post { + val inputConnection = inputConnectionProvider() ?: return@post + + try { + var textToCommit = formatTextWithAutoCapitalization(text) + + // Add spacing rules + val textBeforeCursor = inputConnection.getTextBeforeCursor(10, 0) + if (textBeforeCursor != null && textBeforeCursor.isNotEmpty()) { + val lastChar = textBeforeCursor.last() + if (lastChar.isLetter()) { + textToCommit = " $textToCommit" + } + } + + // Always add space at the end + textToCommit += " " + + inputConnection.commitText(textToCommit, 1) + Log.d(TAG, "Inserted recognized text: '$textToCommit'") + } catch (e: Exception) { + Log.e(TAG, "Error inserting text", e) + } + } + } + + /** + * Initializes the OpenRouter client + */ + private fun ensureOpenRouterClient(): Boolean { + if (openRouterClient != null) { + return true + } + + try { + val apiKey = SettingsManager.getOpenRouterApiKey(context) + if (apiKey.isEmpty()) { + Log.e(TAG, "OpenRouter API key not configured") + onError?.invoke(context.getString(R.string.openai_error_api_key_missing)) + return false + } + + openRouterClient = OpenRouterWhisperClient(apiKey) + Log.d(TAG, "OpenRouter client initialized") + return true + } catch (e: Exception) { + Log.e(TAG, "Error initializing OpenRouter client", e) + onError?.invoke(context.getString(R.string.openai_error_initialization)) + return false + } + } + + /** + * Starts OpenRouter Whisper speech recognition + */ + fun startRecognition() { + // Check RECORD_AUDIO permission + if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) + != android.content.pm.PackageManager.PERMISSION_GRANTED) { + Log.e(TAG, "RECORD_AUDIO permission not granted") + onError?.invoke(context.getString(R.string.speech_recognition_error_permission)) + return + } + + if (isRecognizing) { + Log.w(TAG, "Already recognizing") + return + } + + // Ensure client is initialized + if (!ensureOpenRouterClient()) { + return + } + + try { + isRecognizing = true + onRecognitionStateChanged?.invoke(true) + + // Initialize recorder + whisperRecorder = WhisperRecorder(context).apply { + setListener(object : WhisperRecorder.RecorderListener { + override fun onRecordingStarted() { + Log.d(TAG, "Recording started") + } + + override fun onRecording() { + // Update audio level feedback + onAudioLevelChanged?.invoke(10f) + } + + override fun onRecordingDone() { + Log.d(TAG, "Recording done, starting transcription via OpenRouter API") + onAudioLevelChanged?.invoke(-20f) + startTranscription() + } + + override fun onRecordingError(error: String) { + Log.e(TAG, "Recording error: $error") + isRecognizing = false + onRecognitionStateChanged?.invoke(false) + onError?.invoke(error) + } + }) + + initVad() + start() + } + + Log.d(TAG, "OpenRouter Whisper recognition started") + } catch (e: Exception) { + Log.e(TAG, "Error starting OpenRouter Whisper recognition", e) + isRecognizing = false + onRecognitionStateChanged?.invoke(false) + onError?.invoke(context.getString(R.string.openai_error_generic)) + } + } + + /** + * Starts transcription after recording is complete + */ + private fun startTranscription() { + if (isProcessing) { + Log.w(TAG, "Already processing") + return + } + + isProcessing = true + + // No toast - auto-stop is more intuitive + Log.d(TAG, "Processing audio via OpenRouter API...") + + // Run transcription in background + CoroutineScope(Dispatchers.IO).launch { + try { + // Get the recorded audio buffer + val audioByteBuffer = WhisperRecordBuffer.getOutputBuffer() + if (audioByteBuffer == null || audioByteBuffer.isEmpty()) { + Log.w(TAG, "No audio buffer") + withContext(Dispatchers.Main) { + isProcessing = false + isRecognizing = false + onRecognitionStateChanged?.invoke(false) + onError?.invoke(context.getString(R.string.speech_recognition_error_no_match)) + } + return@launch + } + + Log.d(TAG, "Audio buffer size: ${audioByteBuffer.size} bytes") + + // Get selected model and language + val model = SettingsManager.getOpenRouterModel(context) + var language = SettingsManager.getOpenRouterLanguage(context).takeIf { it.isNotEmpty() } + + // Fall back to system language if not set + if (language.isNullOrEmpty()) { + language = java.util.Locale.getDefault().language + Log.d(TAG, "Using system language: $language") + } + + Log.d(TAG, "Calling OpenRouter API with model=$model, language=$language") + Log.d(TAG, "Audio file size: ${audioByteBuffer.size} bytes") + + // Call OpenRouter API + val result = openRouterClient?.transcribeAudio( + audioByteBuffer, + model, + language + ) + + withContext(Dispatchers.Main) { + stopSpeechRecognition() + + if (result?.isSuccess == true) { + val transcriptionResult = result.getOrNull() + if (transcriptionResult != null && transcriptionResult.text.isNotEmpty()) { + Log.d(TAG, "Transcription successful: '${transcriptionResult.text}'") + + // Save usage statistics + try { + val statsManager = UsageStatsManager(context) + // Count words by counting spaces + 1 (if text is not empty) + val wordCount = if (transcriptionResult.text.isEmpty()) { + 0 + } else { + transcriptionResult.text.split("\\s+".toRegex()).filter { it.isNotEmpty() }.size + } + // Audio duration: audioByteBuffer size / (16000 Hz * 2 bytes per sample) + val audioLengthMs = audioByteBuffer?.size?.toLong()?.times(1000L)?.div(32000L) ?: 0L + + val stat = TranscriptionStats( + id = java.util.UUID.randomUUID().toString(), + engine = "openrouter", + model = transcriptionResult.model, + timestamp = java.time.LocalDateTime.now().toString(), + audioLengthMs = audioLengthMs, + textLength = transcriptionResult.text.length, + wordCount = wordCount, + costUsd = transcriptionResult.costUsd, + successFul = true + ) + statsManager.addStat(stat) + + // Also save per-model breakdown + val prefs = context.getSharedPreferences("usage_stats", android.content.Context.MODE_PRIVATE) + val modelKey = "model_${stat.model}".replace("/", "_").replace("-", "_") + val currentCount = prefs.getLong("${modelKey}_count", 0L) + val currentWords = prefs.getLong("${modelKey}_words", 0L) + val currentCost = prefs.getFloat("${modelKey}_cost", 0f) + + prefs.edit().apply { + putLong("${modelKey}_count", currentCount + 1) + putLong("${modelKey}_words", currentWords + wordCount) + putFloat("${modelKey}_cost", (currentCost + stat.costUsd).toFloat()) + apply() + } + + Log.d(TAG, "Stats saved - Model: ${stat.model}, Words: $wordCount, WPM: ${"%.1f".format(stat.getWordsPerMinute())}, Cost: $${stat.costUsd}") + } catch (e: Exception) { + Log.e(TAG, "Error saving stats: ${e.message}") + } + + insertRecognizedText(transcriptionResult.text) + } else { + Log.w(TAG, "No transcription result") + onError?.invoke(context.getString(R.string.speech_recognition_error_no_match)) + } + } else { + val errorMsg = result?.exceptionOrNull()?.message ?: "Unknown error" + Log.e(TAG, "Transcription failed: $errorMsg") + onError?.invoke(context.getString(R.string.openai_error_generic)) + } + } + } catch (e: Exception) { + Log.e(TAG, "Error during transcription", e) + withContext(Dispatchers.Main) { + isProcessing = false + isRecognizing = false + onRecognitionStateChanged?.invoke(false) + onError?.invoke(context.getString(R.string.openai_error_generic)) + } + } + } + } + + /** + * Stops recognition if active + */ + fun stopRecognition() { + whisperRecorder?.stop() + isRecognizing = false + isProcessing = false + onRecognitionStateChanged?.invoke(false) + Log.d(TAG, "OpenRouter Whisper recognition stopped") + } + + /** + * Stops speech recognition (internal) + */ + private fun stopSpeechRecognition() { + whisperRecorder?.stop() + isRecognizing = false + isProcessing = false + onRecognitionStateChanged?.invoke(false) + } + + /** + * Releases all resources + */ + fun destroy() { + whisperRecorder?.release() + whisperRecorder = null + WhisperRecordBuffer.clear() + isRecognizing = false + isProcessing = false + Log.d(TAG, "OpenRouter Whisper recognition manager destroyed") + } +} + diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/UsageStats.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/UsageStats.kt new file mode 100644 index 00000000..ae9da6f7 --- /dev/null +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/UsageStats.kt @@ -0,0 +1,139 @@ +package it.palsoftware.pastiera.inputmethod.whisper + +import android.content.Context +import android.content.SharedPreferences +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +/** + * Data class for tracking transcription usage statistics + */ +data class TranscriptionStats( + val id: String = "", // Unique ID for this transcription + val engine: String = "", // "openai" or "openrouter" + val model: String = "", // Model name used (e.g. "gpt-4o", "google/gemini-2.5-flash") + val timestamp: String = "", // ISO 8601 timestamp + val audioLengthMs: Long = 0L, // Duration of audio in milliseconds + val textLength: Int = 0, // Character count of result + val wordCount: Int = 0, // Word count of result (calculated from spaces) + val costUsd: Double = 0.0, // Cost in USD + val successFul: Boolean = false // Whether transcription was successful +) { + /** + * Calculate Words Per Minute + */ + fun getWordsPerMinute(): Double { + if (audioLengthMs <= 0 || wordCount <= 0) return 0.0 + val minutes = audioLengthMs / 1000.0 / 60.0 + return wordCount / minutes + } + + /** + * Calculate Characters Per Second + */ + fun getCharactersPerSecond(): Double { + if (audioLengthMs <= 0 || textLength <= 0) return 0.0 + val seconds = audioLengthMs / 1000.0 + return textLength / seconds + } +} + +/** + * Aggregated statistics by model + */ +data class ModelStats( + val model: String = "", + val transcriptionCount: Int = 0, + val totalWords: Int = 0, + val totalCostUsd: Double = 0.0, + val totalDurationSeconds: Long = 0L, + val averageWPM: Double = 0.0 +) + +/** + * Manages transcription usage statistics persistence + */ +class UsageStatsManager(private val context: Context) { + companion object { + private const val PREFS_NAME = "usage_stats" + private const val KEY_TOTAL_COST = "total_cost_usd" + private const val KEY_TOTAL_TOKENS = "total_tokens" + private const val KEY_TRANSCRIPTION_COUNT = "transcription_count" + private const val KEY_TOTAL_DURATION = "total_duration_ms" + private const val KEY_STATS_LIST = "stats_list" + } + + private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + /** + * Add a transcription to the stats + */ + fun addStat(stat: TranscriptionStats) { + if (!stat.successFul) return + + // Update totals + val currentCost = prefs.getFloat(KEY_TOTAL_COST, 0f).toDouble() + val currentTokens = prefs.getLong(KEY_TOTAL_TOKENS, 0L) + val currentCount = prefs.getInt(KEY_TRANSCRIPTION_COUNT, 0) + val currentDuration = prefs.getLong(KEY_TOTAL_DURATION, 0L) + + prefs.edit().apply { + putFloat(KEY_TOTAL_COST, (currentCost + stat.costUsd).toFloat()) + putLong(KEY_TOTAL_TOKENS, currentTokens + stat.wordCount) // Store word count here (not tokens) + putInt(KEY_TRANSCRIPTION_COUNT, currentCount + 1) + putLong(KEY_TOTAL_DURATION, currentDuration + stat.audioLengthMs) + apply() + } + } + + /** + * Get total cost in USD + */ + fun getTotalCostUsd(): Double { + return prefs.getFloat(KEY_TOTAL_COST, 0f).toDouble() + } + + /** + * Get total tokens used + */ + fun getTotalTokens(): Long { + return prefs.getLong(KEY_TOTAL_TOKENS, 0L) + } + + /** + * Get number of transcriptions + */ + fun getTranscriptionCount(): Int { + return prefs.getInt(KEY_TRANSCRIPTION_COUNT, 0) + } + + /** + * Get total audio duration in seconds + */ + fun getTotalDurationSeconds(): Long { + return prefs.getLong(KEY_TOTAL_DURATION, 0L) / 1000 + } + + /** + * Get average WPM across all transcriptions + */ + fun getAverageWPM(): Double { + val count = getTranscriptionCount() + if (count == 0) return 0.0 + + val totalDurationMin = getTotalDurationSeconds() / 60.0 + if (totalDurationMin <= 0) return 0.0 + + // Estimate: ~5 characters per word average in transcript + val estimatedWords = getTotalCostUsd() * 100000 // Placeholder - needs actual word count + return estimatedWords / totalDurationMin + } + + /** + * Reset all stats + */ + fun resetStats() { + prefs.edit().clear().apply() + } +} + diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/UsageStatsUI.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/UsageStatsUI.kt new file mode 100644 index 00000000..b2401e80 --- /dev/null +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/UsageStatsUI.kt @@ -0,0 +1,395 @@ +package it.palsoftware.pastiera.inputmethod.whisper + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BarChart +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +/** + * UI Card for displaying transcription usage statistics + */ +@Composable +fun UsageStatsCard( + modifier: Modifier = Modifier, + onReset: () -> Unit = {} +) { + val context = LocalContext.current + val statsManager = remember { UsageStatsManager(context) } + + var totalCost by remember { mutableStateOf(statsManager.getTotalCostUsd()) } + var tokenCount by remember { mutableStateOf(statsManager.getTotalTokens()) } + var transcriptionCount by remember { mutableStateOf(statsManager.getTranscriptionCount()) } + var totalDuration by remember { mutableStateOf(statsManager.getTotalDurationSeconds()) } + var showModelBreakdown by remember { mutableStateOf(false) } // Toggle for model stats + + // Refresh stats when composable recomposes + LaunchedEffect(Unit) { + totalCost = statsManager.getTotalCostUsd() + tokenCount = statsManager.getTotalTokens() + transcriptionCount = statsManager.getTranscriptionCount() + totalDuration = statsManager.getTotalDurationSeconds() + } + + Surface( + modifier = modifier + .fillMaxWidth() + .padding(16.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.medium, + tonalElevation = 4.dp + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Header + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Filled.BarChart, + contentDescription = "Usage Statistics", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + Text( + text = "Usage Statistics", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + } + + // Reset button + if (transcriptionCount > 0) { + IconButton( + onClick = { + statsManager.resetStats() + totalCost = 0.0 + tokenCount = 0L + transcriptionCount = 0 + totalDuration = 0L + onReset() + }, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = "Reset stats", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + Divider() + + // Model Breakdown Toggle Button + if (transcriptionCount > 0) { + OutlinedButton( + onClick = { showModelBreakdown = !showModelBreakdown }, + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + Text(if (showModelBreakdown) "Hide Model Breakdown ▼" else "Show Model Breakdown ▶") + } + } + + // Stats Grid + if (transcriptionCount > 0) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Row 1: Cost and Tokens + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + StatItem( + label = "Total Cost", + value = "${"%.4f".format(totalCost)}", + unit = "USD", + modifier = Modifier.weight(1f) + ) + + StatItem( + label = "Total Tokens", + value = tokenCount.toString(), + unit = "", + modifier = Modifier.weight(1f) + ) + } + + // Row 2: Transcriptions and Duration + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + StatItem( + label = "Transcriptions", + value = transcriptionCount.toString(), + unit = "", + modifier = Modifier.weight(1f) + ) + + StatItem( + label = "Total Duration", + value = formatDuration(totalDuration), + unit = "", + modifier = Modifier.weight(1f) + ) + } + + // Row 3: Cost per transcription and WPM + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + StatItem( + label = "Avg Cost/Transcription", + value = "${"%.5f".format(if (transcriptionCount > 0) totalCost / transcriptionCount else 0.0)}", + unit = "USD", + modifier = Modifier.weight(1f) + ) + + StatItem( + label = "Avg WPM", + value = calculateAverageWPM(totalDuration, tokenCount), + unit = "words/min", + modifier = Modifier.weight(1f) + ) + } + } + + // Model Breakdown Section + if (showModelBreakdown) { + Divider(modifier = Modifier.padding(vertical = 12.dp)) + + Text( + text = "Usage by Model", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp) + ) + + // Get model breakdown from shared preferences + val modelStats = getModelBreakdownStats(context) + + if (modelStats.isEmpty()) { + Text( + text = "No model data available", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(8.dp) + ) + } else { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + modelStats.forEach { (model, count, words, cost) -> + ModelBreakdownItem( + model = model, + count = count, + words = words, + cost = cost + ) + } + } + } + } + } else { + Text( + text = "No transcriptions yet. Start using speech recognition to see statistics.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) + } + } + } +} + +/** + * Individual stat display item + */ +@Composable +private fun StatItem( + label: String, + value: String, + unit: String = "", + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = value, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + if (unit.isNotEmpty()) { + Text( + text = unit, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +/** + * Format duration in seconds to readable format + */ +private fun formatDuration(seconds: Long): String { + return when { + seconds < 60 -> "${seconds}s" + seconds < 3600 -> "${seconds / 60}m ${seconds % 60}s" + else -> "${seconds / 3600}h ${(seconds % 3600) / 60}m" + } +} + +/** + * Calculate average Words Per Minute + * tokenCount is actually wordCount now, not tokens + */ +private fun calculateAverageWPM(totalDurationSeconds: Long, wordCount: Long): String { + if (totalDurationSeconds <= 0 || wordCount <= 0) return "0.0" + val minutes = totalDurationSeconds / 60.0 + val wpm = wordCount / minutes + return "${"%.1f".format(wpm)}" +} + +/** + * Get model breakdown statistics from SharedPreferences + * Returns list of (model, count, words, cost) + */ +private fun getModelBreakdownStats(context: android.content.Context): List> { + val prefs = context.getSharedPreferences("usage_stats", android.content.Context.MODE_PRIVATE) + val allModels = prefs.all + + val modelStats = mutableMapOf>() // model -> (count, words, cost) + + // Parse all stored stats to aggregate by model + for ((key, value) in allModels) { + if (key.startsWith("model_")) { + // Format: model_[modelName]_count, model_[modelName]_words, model_[modelName]_cost + val parts = key.split("_") + if (parts.size >= 3) { + val modelName = parts.drop(1).dropLast(1).joinToString("_") + val field = parts.last() + + when (field) { + "count" -> { + val current = modelStats[modelName] ?: Tuple3(0, 0, 0.0) + modelStats[modelName] = Tuple3((value as? Long)?.toInt() ?: 0, current.second, current.third) + } + "words" -> { + val current = modelStats[modelName] ?: Tuple3(0, 0, 0.0) + modelStats[modelName] = Tuple3(current.first, (value as? Long)?.toInt() ?: 0, current.third) + } + "cost" -> { + val current = modelStats[modelName] ?: Tuple3(0, 0, 0.0) + modelStats[modelName] = Tuple3(current.first, current.second, (value as? Float)?.toDouble() ?: 0.0) + } + } + } + } + } + + return modelStats.map { (model, stats) -> + Tuple4(model, stats.first, stats.second, stats.third) + }.sortedByDescending { it.second } // Sort by count (descending) +} + +// Simple tuple classes +data class Tuple3(val first: A, val second: B, val third: C) +data class Tuple4(val first: A, val second: B, val third: C, val fourth: D) + +/** + * Composable for displaying a single model's breakdown + */ +@Composable +private fun ModelBreakdownItem( + model: String, + count: Int, + words: Int, + cost: Double +) { + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = model, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + maxLines = 1 + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "$count ×", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "$words words", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + if (cost > 0) { + Text( + text = "${"%.4f".format(cost)} USD", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } +} + diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WavAudioWriter.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WavAudioWriter.kt new file mode 100644 index 00000000..a018fb4c --- /dev/null +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WavAudioWriter.kt @@ -0,0 +1,152 @@ +package it.palsoftware.pastiera.inputmethod.whisper + +import android.util.Log +import java.io.File +import java.io.RandomAccessFile +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * Writes PCM16 audio data to a WAV file with proper headers. + * Used for debugging audio capture and transcription issues. + */ +object WavAudioWriter { + private const val TAG = "WavAudioWriter" + + // Audio parameters matching WhisperRecorder + private const val SAMPLE_RATE = 16000 // 16kHz + private const val CHANNELS = 1 // Mono + private const val BITS_PER_SAMPLE = 16 // PCM16 + + /** + * Writes PCM16 audio bytes to a WAV file + */ + fun writeWavFile( + audioBytes: ByteArray, + outputFile: File + ): Boolean { + return try { + val numSamples = audioBytes.size / 2 // 16-bit = 2 bytes per sample + + Log.d(TAG, "Writing WAV file: ${outputFile.absolutePath}") + Log.d(TAG, "Audio data: ${audioBytes.size} bytes ($numSamples samples)") + Log.d(TAG, "Duration: ${numSamples / SAMPLE_RATE.toFloat()} seconds") + + RandomAccessFile(outputFile, "rw").use { file -> + // WAV Header (44 bytes) + val header = createWavHeader(audioBytes.size) + file.write(header) + file.write(audioBytes) + + Log.d(TAG, "WAV file written successfully: ${outputFile.length()} bytes") + } + true + } catch (e: Exception) { + Log.e(TAG, "Failed to write WAV file", e) + false + } + } + + /** + * Creates a proper WAV file header for PCM16 mono audio + * Header format: + * - RIFF header: 12 bytes + * - fmt subchunk: 24 bytes + * - data subchunk: 8 bytes + audio data + */ + private fun createWavHeader(audioDataSize: Int): ByteArray { + val header = ByteArray(44) + val buffer = ByteBuffer.wrap(header).order(ByteOrder.LITTLE_ENDIAN) + + val byteRate = SAMPLE_RATE * CHANNELS * BITS_PER_SAMPLE / 8 + val blockAlign = CHANNELS * BITS_PER_SAMPLE / 8 + val fileSize = 36 + audioDataSize + + // RIFF header + buffer.position(0) + buffer.put("RIFF".toByteArray()) + buffer.putInt(fileSize) // File size - 8 + buffer.put("WAVE".toByteArray()) + + // fmt subchunk + buffer.put("fmt ".toByteArray()) // Subchunk1ID + buffer.putInt(16) // Subchunk1Size (16 for PCM) + buffer.putShort(1) // AudioFormat (1 for PCM) + buffer.putShort(CHANNELS.toShort()) + buffer.putInt(SAMPLE_RATE) + buffer.putInt(byteRate) + buffer.putShort(blockAlign.toShort()) + buffer.putShort(BITS_PER_SAMPLE.toShort()) + + // data subchunk + buffer.put("data".toByteArray()) // Subchunk2ID + buffer.putInt(audioDataSize) // Subchunk2Size + + return header + } + + /** + * Converts PCM16 ByteArray to FloatArray for inspection (normalized to [-1.0, 1.0]) + */ + fun pcm16ToFloat(pcm16Bytes: ByteArray): FloatArray { + val byteBuffer = ByteBuffer.wrap(pcm16Bytes).order(ByteOrder.LITTLE_ENDIAN) + val shortBuffer = byteBuffer.asShortBuffer() + val floatBuffer = FloatArray(shortBuffer.remaining()) + + for (i in floatBuffer.indices) { + floatBuffer[i] = shortBuffer.get(i) / 32768.0f + } + + return floatBuffer + } + + /** + * Logs audio statistics for debugging + */ + fun logAudioStats(audioBytes: ByteArray) { + try { + val samples = pcm16ToFloat(audioBytes) + if (samples.isEmpty()) { + Log.d(TAG, "Audio Stats: No samples") + return + } + + var minValue = samples[0] + var maxValue = samples[0] + var rmsSum = 0.0f + + for (sample in samples) { + if (sample < minValue) minValue = sample + if (sample > maxValue) maxValue = sample + rmsSum += sample * sample + } + + val rms = kotlin.math.sqrt((rmsSum / samples.size).toDouble()).toFloat() + val peakLevel = kotlin.math.max(kotlin.math.abs(minValue), kotlin.math.abs(maxValue)) + + Log.d(TAG, "Audio Stats:") + Log.d(TAG, " Samples: ${samples.size}") + Log.d(TAG, " Duration: ${samples.size / SAMPLE_RATE.toFloat()}s") + Log.d(TAG, " Min: $minValue") + Log.d(TAG, " Max: $maxValue") + Log.d(TAG, " Peak: $peakLevel") + Log.d(TAG, " RMS: $rms") + + // Detect if audio is mostly silent + if (peakLevel < 0.01f) { + Log.w(TAG, "⚠️ WARNING: Audio is extremely quiet or silent!") + } else if (peakLevel < 0.05f) { + Log.w(TAG, "⚠️ WARNING: Audio is very quiet") + } + + // Detect if audio is clipping + if (peakLevel > 0.95f) { + Log.w(TAG, "⚠️ WARNING: Audio is clipping!") + } + } catch (e: Exception) { + Log.e(TAG, "Failed to calculate audio stats", e) + } + } +} + + diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperModel.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperModel.kt index d29cd129..9cdaf55e 100644 --- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperModel.kt +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperModel.kt @@ -1,7 +1,11 @@ package it.palsoftware.pastiera.inputmethod.whisper /** - * Represents available Whisper models for speech recognition. + * Represents available Whisper ONNX models for speech recognition. + * All models are from DocWolle's curated ONNX-accelerated collection: + * https://huggingface.co/DocWolle/whisperOnnx + * + * Optimized for mobile with INT8 quantization and KV-Cache support. */ enum class WhisperModel( val displayName: String, @@ -10,26 +14,12 @@ enum class WhisperModel( val isMultilingual: Boolean, val description: String ) { - TINY_EN( - displayName = "Tiny (English only)", - fileName = "whisper-tiny.en.tflite", - sizeBytes = 42 * 1024 * 1024, // ~42 MB - isMultilingual = false, - description = "Fast, English only, good for clear speech" - ), - BASE( - displayName = "Base (Multilingual)", - fileName = "whisper-base.TOP_WORLD.tflite", - sizeBytes = 108 * 1024 * 1024, // ~108 MB - isMultilingual = true, - description = "Balanced quality and speed, supports top world languages" - ), SMALL( - displayName = "Small (Multilingual)", - fileName = "whisper-small.tflite", - sizeBytes = 388 * 1024 * 1024, // ~388 MB + displayName = "Small (Multilingual) - Recommended", + fileName = "whisper_small_int8.zip", + sizeBytes = 243 * 1024 * 1024, // 243 MB isMultilingual = true, - description = "Excellent quality, may be slower on some devices" + description = "Excellent quality, INT8 quantized, supports 99 languages" ); companion object { @@ -41,9 +31,11 @@ enum class WhisperModel( /** * Download URLs for Whisper models from Hugging Face. + * Models are from DocWolle's curated ONNX-accelerated collection: + * https://huggingface.co/DocWolle/whisperOnnx */ object WhisperModelUrls { - private const val BASE_URL = "https://huggingface.co/DocWolle/whisper_tflite_models/resolve/main" + private const val BASE_URL = "https://huggingface.co/DocWolle/whisperOnnx/resolve/main" fun getDownloadUrl(model: WhisperModel): String { return "$BASE_URL/${model.fileName}" @@ -59,6 +51,16 @@ object WhisperModelUrls { data class WhisperResult( val text: String, val language: String, - val confidence: Float = 1.0f + val confidence: Float = 1.0f, + val model: String = "unknown", + val promptTokens: Int = 0, + val completionTokens: Int = 0, + val costUsd: Double = 0.0 +) + +data class UsageInfo( + val promptTokens: Int = 0, + val completionTokens: Int = 0, + val costUsd: Double = 0.0 ) diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperModelDownloader.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperModelDownloader.kt index b85bb477..129181a9 100644 --- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperModelDownloader.kt +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperModelDownloader.kt @@ -9,6 +9,7 @@ import okhttp3.Request import java.io.File import java.io.FileOutputStream import java.util.concurrent.TimeUnit +import java.util.zip.ZipInputStream /** * Downloads Whisper models from Hugging Face. @@ -44,13 +45,28 @@ class WhisperModelDownloader(private val context: Context) { } /** - * Checks if a model is downloaded. + * Checks if a model is downloaded and extracted. + * For DocWolle ONNX models: checks for ONNX files in the models directory. */ fun isModelDownloaded(model: WhisperModel): Boolean { - val modelFile = File(getModelsDir(), model.fileName) - val vocabFile = getVocabFile(model) - return modelFile.exists() && vocabFile.exists() && - modelFile.length() > 0 && vocabFile.length() > 0 + val modelsDir = getModelsDir() + if (!modelsDir.exists()) { + Log.d(TAG, "Models directory doesn't exist: ${modelsDir.absolutePath}") + return false + } + + val modelFiles = modelsDir.listFiles() ?: return false + + // For ONNX models: check if there are extracted ONNX files + val hasOnnxFiles = modelFiles.any { it.extension == "onnx" } + + if (!hasOnnxFiles) { + Log.d(TAG, "No ONNX files found. Model not downloaded.") + return false + } + + Log.d(TAG, "Model '${model.displayName}' is downloaded. Found ${modelFiles.count { it.extension == "onnx" }} ONNX files") + return true } /** @@ -66,13 +82,14 @@ class WhisperModelDownloader(private val context: Context) { } /** - * Gets the size of downloaded model files. + * Gets the total size of downloaded ONNX model files. */ fun getDownloadedSize(model: WhisperModel): Long { - val modelFile = File(getModelsDir(), model.fileName) - val vocabFile = getVocabFile(model) - return (if (modelFile.exists()) modelFile.length() else 0) + - (if (vocabFile.exists()) vocabFile.length() else 0) + val modelsDir = getModelsDir() + val modelFiles = modelsDir.listFiles() ?: return 0L + + // Sum size of all ONNX files (they're the actual models) + return modelFiles.filter { it.extension == "onnx" }.sumOf { it.length() } } /** @@ -104,7 +121,20 @@ class WhisperModelDownloader(private val context: Context) { // Download model file val modelUrl = WhisperModelUrls.getDownloadUrl(model) Log.d(TAG, "Downloading model from $modelUrl") - downloadFile(modelUrl, modelFile, listener) + + // If URL ends with .zip, download and extract it + if (modelUrl.endsWith(".zip")) { + val zipFile = File(modelsDir, "${model.fileName}.zip") + downloadFile(modelUrl, zipFile, listener) + + Log.d(TAG, "Extracting ZIP file...") + extractZipFile(zipFile, modelsDir) + zipFile.delete() // Clean up ZIP after extraction + Log.d(TAG, "Extraction complete") + } else { + // Direct ONNX file download + downloadFile(modelUrl, modelFile, listener) + } Log.d(TAG, "Download complete: ${modelFile.absolutePath}") Result.success(modelFile) @@ -213,6 +243,47 @@ class WhisperModelDownloader(private val context: Context) { } } + /** + * Extracts a ZIP file containing ONNX models. + * DocWolle's models are packaged in ZIP format. + */ + private fun extractZipFile(zipFile: File, targetDir: File) { + Log.d(TAG, "Extracting ZIP file: ${zipFile.name}") + + ZipInputStream(zipFile.inputStream()).use { zipInput -> + var zipEntry = zipInput.nextEntry + + while (zipEntry != null) { + val entryName = zipEntry.name + + // Skip directories + if (zipEntry.isDirectory) { + zipEntry = zipInput.nextEntry + continue + } + + // Extract only .onnx and .bin files + if (entryName.endsWith(".onnx") || entryName.endsWith(".bin")) { + // Extract just the filename (ignore directory structure in ZIP) + val fileName = entryName.substringAfterLast('/') + val outputFile = File(targetDir, fileName) + + Log.d(TAG, "Extracting: $entryName -> ${outputFile.name}") + + outputFile.outputStream().use { fileOutput -> + zipInput.copyTo(fileOutput) + } + + Log.d(TAG, "Extracted: ${outputFile.name} (${outputFile.length()} bytes)") + } + + zipEntry = zipInput.nextEntry + } + } + + Log.d(TAG, "ZIP extraction complete") + } + /** * Deletes a downloaded model. */ diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperOnnxManager.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperOnnxManager.kt new file mode 100644 index 00000000..4e3faa99 --- /dev/null +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperOnnxManager.kt @@ -0,0 +1,271 @@ +package it.palsoftware.pastiera.inputmethod.whisper + +import android.app.ActivityManager +import android.content.Context +import android.util.Log +import ai.onnxruntime.* +import java.io.File + +/** + * Manages ONNX Runtime sessions for Whisper speech recognition. + * Loads and manages the 5 separate ONNX models (optimized from RTranslator). + * + * Models: + * 1. initSession - Audio PCM → Mel-Spectrogram + * 2. encoderSession - Mel-Spectrogram → Encoder Hidden States + * 3. cacheInitSession - Initialize KV-Cache + * 4. decoderSession - Iterative token generation with KV-Cache + * 5. detokenizerSession - Token sequence → Text + */ +class WhisperOnnxManager(private val context: Context) { + + private var initSession: OrtSession? = null + private var encoderSession: OrtSession? = null + private var cacheInitSession: OrtSession? = null + private var decoderSession: OrtSession? = null + private var detokenizerSession: OrtSession? = null + private var onnxEnv: OrtEnvironment? = null + + private val TAG = "WhisperOnnxManager" + // Directory where downloaded models are stored (by WhisperModelDownloader) + private val MODELS_DIR = File(context.getExternalFilesDir(null), "whisper_models") + + /** + * Initializes and loads all 5 ONNX sessions. + * Models must be pre-downloaded via WhisperModelDownloader. + */ + fun initialize(): Result { + return try { + Log.d(TAG, "Initializing ONNX Environment...") + onnxEnv = OrtEnvironment.getEnvironment() + + // Check if models are available + if (!isModelsAvailable()) { + return Result.failure(Exception("Whisper models not found. Please download them first.")) + } + + Log.d(TAG, "Loading ONNX sessions...") + loadSessions() + + Log.d(TAG, "✅ All ONNX sessions initialized successfully") + Result.success(Unit) + } catch (e: Exception) { + Log.e(TAG, "Error initializing ONNX Manager", e) + Result.failure(e) + } + } + + /** + * Loads all 5 ONNX sessions from downloaded models. + */ + private fun loadSessions() { + val env = onnxEnv ?: throw IllegalStateException("ONNX Environment not initialized") + + try { + // Find the main model file (the ZIP that was extracted) + val modelFiles = MODELS_DIR.listFiles() ?: emptyArray() + val onnxFiles = modelFiles.filter { it.extension == "onnx" } + + if (onnxFiles.isEmpty()) { + throw IllegalStateException("No ONNX files found in ${MODELS_DIR.absolutePath}") + } + + // Extract the base path from the first ONNX file (they should all be in same dir) + val baseDir = onnxFiles[0].parentFile?.absolutePath ?: MODELS_DIR.absolutePath + + Log.d(TAG, "Found ONNX models in: $baseDir") + onnxFiles.forEach { Log.d(TAG, " - ${it.name} (${it.length() / (1024 * 1024)} MB)") } + + // Load the 5 sessions + // 1. Initializer (Mel-Spectrogram generation) + val initPath = findModelFile(onnxFiles, "initializer", "init") + if (initPath != null) { + Log.d(TAG, "Loading initializer session from $initPath") + initSession = createSession(env, initPath, "init") + } + + // 2. Encoder + val encoderPath = findModelFile(onnxFiles, "encoder") + if (encoderPath != null) { + Log.d(TAG, "Loading encoder session from $encoderPath") + encoderSession = createSession(env, encoderPath, "encoder") + } + + // 3. Cache Initializer + val cacheInitPath = findModelFile(onnxFiles, "cache_initializer", "cache_init") + if (cacheInitPath != null) { + Log.d(TAG, "Loading cache init session from $cacheInitPath") + cacheInitSession = createSession(env, cacheInitPath, "cache_init") + } + + // 4. Decoder + val decoderPath = findModelFile(onnxFiles, "decoder") + if (decoderPath != null) { + Log.d(TAG, "Loading decoder session from $decoderPath") + decoderSession = createSession(env, decoderPath, "decoder") + } + + // 5. Detokenizer + val detokenizerPath = findModelFile(onnxFiles, "detokenizer") + if (detokenizerPath != null) { + Log.d(TAG, "Loading detokenizer session from $detokenizerPath") + detokenizerSession = createSession(env, detokenizerPath, "detokenizer") + } + + // Verify all sessions are loaded + if (initSession == null || encoderSession == null || cacheInitSession == null || + decoderSession == null || detokenizerSession == null) { + throw IllegalStateException("Failed to load one or more ONNX models") + } + + Log.d(TAG, "✅ All 5 ONNX sessions loaded successfully") + + } catch (e: Exception) { + Log.e(TAG, "Error loading sessions: ${e.message}", e) + throw e + } + } + + /** + * Finds an ONNX model file by pattern matching. + */ + private fun findModelFile(files: List, vararg patterns: String): String? { + for (file in files) { + for (pattern in patterns) { + if (file.name.lowercase().contains(pattern.lowercase())) { + return file.absolutePath + } + } + } + return null + } + + /** + * Creates an ONNX session with optimized settings. + * Based on RTranslator's session configuration. + */ + private fun createSession(env: OrtEnvironment, modelPath: String, sessionType: String): OrtSession { + val sessionOptions = OrtSession.SessionOptions().apply { + + when (sessionType) { + "encoder" -> { + // Encoder uses CPU arena allocation for better performance + val totalMemory = getTotalRamSize() + if (totalMemory <= 7000) { + setCPUArenaAllocator(false) + setMemoryPatternOptimization(false) + } else { + setCPUArenaAllocator(true) + setMemoryPatternOptimization(true) + } + setSymbolicDimensionValue("batch_size", 1) + } + + "cache_init", "decoder", "detokenizer" -> { + // Cache and decoder sessions: register custom op library via reflection (required for KV-Cache) + try { + val ortxPackageClass = Class.forName("ai.onnxruntime.extensions.OrtxPackage") + val getLibraryPathMethod = ortxPackageClass.getMethod("getLibraryPath") + val libraryPath = getLibraryPathMethod.invoke(null) as String + registerCustomOpLibrary(libraryPath) + Log.d(TAG, "✅ Registered OrtxPackage custom op library for $sessionType") + } catch (e: Exception) { + Log.w(TAG, "⚠️ Could not register OrtxPackage: ${e.message}") + Log.w(TAG, "Decoder will work without KV-Cache custom ops - may be slower") + } + setCPUArenaAllocator(false) + setMemoryPatternOptimization(false) + } + } + + // Disable optimization for all sessions to match RTranslator + setOptimizationLevel(OrtSession.SessionOptions.OptLevel.NO_OPT) + } + + try { + val session = env.createSession(modelPath, sessionOptions) + Log.d(TAG, "✅ Created $sessionType session") + return session + } catch (e: OrtException) { + Log.e(TAG, "Failed to create $sessionType session: ${e.message}", e) + throw e + } + } + + /** + * Gets total device RAM in MB. + */ + private fun getTotalRamSize(): Long { + val actManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val memInfo = ActivityManager.MemoryInfo() + actManager.getMemoryInfo(memInfo) + return memInfo.totalMem / 1000000L + } + + /** + * Checks if downloaded models are available. + */ + private fun isModelsAvailable(): Boolean { + if (!MODELS_DIR.exists()) { + Log.e(TAG, "Models directory doesn't exist: ${MODELS_DIR.absolutePath}") + return false + } + + val modelFiles = MODELS_DIR.listFiles() ?: emptyArray() + val hasOnnxFiles = modelFiles.any { it.extension == "onnx" } + + if (!hasOnnxFiles) { + Log.e(TAG, "No ONNX files found in models directory") + return false + } + + Log.d(TAG, "Found ${modelFiles.count { it.extension == "onnx" }} ONNX model files") + return true + } + + // ===== Getters ===== + fun getInitSession() = initSession + fun getEncoderSession() = encoderSession + fun getCacheInitSession() = cacheInitSession + fun getDecoderSession() = decoderSession + fun getDetokenizerSession() = detokenizerSession + fun getOnnxEnv() = onnxEnv + + fun isInitialized(): Boolean { + return initSession != null && encoderSession != null && + cacheInitSession != null && decoderSession != null && + detokenizerSession != null && onnxEnv != null + } + + fun destroy() { + Log.d(TAG, "Destroying ONNX Manager...") + try { + initSession?.close() + encoderSession?.close() + cacheInitSession?.close() + decoderSession?.close() + detokenizerSession?.close() + onnxEnv?.close() + } catch (e: Exception) { + Log.e(TAG, "Error destroying sessions: ${e.message}") + } + } + + companion object { + @Volatile + private var instance: WhisperOnnxManager? = null + + fun getInstance(context: Context): WhisperOnnxManager { + return instance ?: synchronized(this) { + instance ?: WhisperOnnxManager(context.applicationContext).also { + instance = it + } + } + } + + fun destroy() { + instance?.destroy() + instance = null + } + } +} diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperRecognitionManager.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperRecognitionManager.kt index 216c23f8..9e558ce1 100644 --- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperRecognitionManager.kt +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperRecognitionManager.kt @@ -19,8 +19,8 @@ import java.io.File import java.util.Locale /** - * Manages Whisper-based speech recognition. - * Provides an API similar to SpeechRecognitionManager for seamless integration. + * Manages Whisper-based speech recognition using ONNX Runtime. + * Uses DocWolle's optimized ONNX models from HuggingFace. */ class WhisperRecognitionManager( private val context: Context, @@ -31,44 +31,23 @@ class WhisperRecognitionManager( private val onAudioLevelChanged: ((Float) -> Unit)? = null ) { companion object { - private const val TAG = "WhisperRecognitionMgr" - private const val MODELS_DIR = "whisper_models" + private const val TAG = "WhisperOnnxRecognitionMgr" } - private var whisperEngine: WhisperEngine? = null + private var onnxManager: WhisperOnnxManager? = null + private var transcriber: WhisperTranscriber? = null private var whisperRecorder: WhisperRecorder? = null private var isRecognizing = false private var isProcessing = false /** - * Checks if Whisper is available (model is downloaded). + * Checks if Whisper ONNX models are available. */ fun isAvailable(): Boolean { + // Check if models are downloaded val selectedModel = SettingsManager.getWhisperModel(context) - val modelFile = getModelFile(selectedModel) - val vocabFile = getVocabFile(selectedModel) - return modelFile.exists() && vocabFile.exists() - } - - /** - * Gets the model file for the selected Whisper model. - */ - private fun getModelFile(model: WhisperModel): File { - val modelsDir = File(context.getExternalFilesDir(null), MODELS_DIR) - return File(modelsDir, model.fileName) - } - - /** - * Gets the vocab file for the selected Whisper model. - */ - private fun getVocabFile(model: WhisperModel): File { - val modelsDir = File(context.getExternalFilesDir(null), MODELS_DIR) - val vocabFileName = if (model.isMultilingual) { - "filters_vocab_multilingual.bin" - } else { - "filters_vocab_en.bin" - } - return File(modelsDir, vocabFileName) + val downloader = WhisperModelDownloader(context) + return downloader.isModelDownloaded(selectedModel) } /** @@ -140,41 +119,52 @@ class WhisperRecognitionManager( } /** - * Initializes the Whisper engine with the selected model. + * Initializes ONNX Manager and Transcriber. */ - private fun ensureWhisperEngine(): Boolean { - if (whisperEngine?.isInitialized() == true) { + private fun ensureOnnxManager(): Boolean { + if (onnxManager != null && transcriber != null) { return true } try { - val selectedModel = SettingsManager.getWhisperModel(context) - val modelFile = getModelFile(selectedModel) - val vocabFile = getVocabFile(selectedModel) - - if (!modelFile.exists()) { - Log.e(TAG, "Model file not found: ${modelFile.absolutePath}") + // Check if models are actually downloaded + if (!areModelsDownloaded()) { + Log.e(TAG, "Whisper models not downloaded yet") onError?.invoke(context.getString(R.string.whisper_error_model_not_found)) return false } - - if (!vocabFile.exists()) { - Log.e(TAG, "Vocab file not found: ${vocabFile.absolutePath}") + + onnxManager = WhisperOnnxManager.getInstance(context) + val initResult = onnxManager!!.initialize() + + if (!initResult.isSuccess) { + Log.e(TAG, "Failed to initialize ONNX Manager: ${initResult.exceptionOrNull()?.message}") onError?.invoke(context.getString(R.string.whisper_error_model_not_found)) return false } - - whisperEngine = WhisperEngine(context) - whisperEngine?.initialize(modelFile, vocabFile, selectedModel.isMultilingual) - Log.d(TAG, "Whisper engine initialized with model: ${selectedModel.displayName}") + transcriber = WhisperTranscriber(onnxManager!!) + Log.d(TAG, "ONNX Manager and Transcriber initialized") return true } catch (e: Exception) { - Log.e(TAG, "Error initializing Whisper engine", e) + Log.e(TAG, "Error initializing ONNX Manager", e) onError?.invoke(context.getString(R.string.whisper_error_initialization)) return false } } + + /** + * Checks if Whisper models are downloaded. + */ + private fun areModelsDownloaded(): Boolean { + val selectedModel = SettingsManager.getWhisperModel(context) + val downloader = WhisperModelDownloader(context) + + val isDownloaded = downloader.isModelDownloaded(selectedModel) + Log.d(TAG, "Model $selectedModel downloaded: $isDownloaded") + + return isDownloaded + } /** * Starts Whisper speech recognition. @@ -193,8 +183,8 @@ class WhisperRecognitionManager( return } - // Ensure engine is initialized - if (!ensureWhisperEngine()) { + // Ensure ONNX Manager is initialized + if (!ensureOnnxManager()) { return } @@ -210,12 +200,11 @@ class WhisperRecognitionManager( } override fun onRecording() { - // Update audio level feedback (simplified) onAudioLevelChanged?.invoke(10f) } override fun onRecordingDone() { - Log.d(TAG, "Recording done, starting transcription") + Log.d(TAG, "Recording done, starting ONNX transcription") onAudioLevelChanged?.invoke(-20f) startTranscription() } @@ -232,9 +221,9 @@ class WhisperRecognitionManager( start() } - Log.d(TAG, "Whisper recognition started") + Log.d(TAG, "Whisper ONNX recognition started") } catch (e: Exception) { - Log.e(TAG, "Error starting Whisper recognition", e) + Log.e(TAG, "Error starting Whisper ONNX recognition", e) isRecognizing = false onRecognitionStateChanged?.invoke(false) onError?.invoke(context.getString(R.string.whisper_error_generic)) @@ -258,25 +247,52 @@ class WhisperRecognitionManager( } // Run transcription in background - CoroutineScope(Dispatchers.IO).launch { + CoroutineScope(Dispatchers.Default).launch { try { - // Get language token for better recognition - val languageToken = getLanguageToken() - - // Process audio - val result = whisperEngine?.processRecordBuffer(languageToken) + // Get the recorded audio buffer (ByteArray with WAV data) + val audioByteBuffer = WhisperRecordBuffer.getOutputBuffer() + if (audioByteBuffer == null || audioByteBuffer.isEmpty()) { + Log.w(TAG, "No audio buffer") + withContext(Dispatchers.Main) { + isProcessing = false + isRecognizing = false + onRecognitionStateChanged?.invoke(false) + onError?.invoke(context.getString(R.string.speech_recognition_error_no_match)) + } + return@launch + } + + Log.d(TAG, "Audio buffer size: ${audioByteBuffer.size} bytes") + + // Convert ByteArray (WAV) to FloatArray (PCM samples) + val audioBuffer = convertWavToFloatArray(audioByteBuffer) + + // Get selected language (use OpenAI language setting as default) + val languageCode = SettingsManager.getOpenAiLanguage(context) + Log.d(TAG, "Calling WhisperTranscriber with language=$languageCode") + + // Call ONNX Transcriber + val result = transcriber?.transcribe(audioBuffer, languageCode) + withContext(Dispatchers.Main) { isProcessing = false isRecognizing = false onRecognitionStateChanged?.invoke(false) - if (result != null && result.text.isNotEmpty()) { - Log.d(TAG, "Transcription result: '${result.text}' (language: ${result.language})") - insertRecognizedText(result.text) + if (result?.isSuccess == true) { + val text = result.getOrNull() + if (!text.isNullOrEmpty()) { + Log.d(TAG, "Transcription successful: '$text'") + insertRecognizedText(text) + } else { + Log.w(TAG, "Empty transcription result") + onError?.invoke(context.getString(R.string.speech_recognition_error_no_match)) + } } else { - Log.w(TAG, "No transcription result") - onError?.invoke(context.getString(R.string.speech_recognition_error_no_match)) + val errorMsg = result?.exceptionOrNull()?.message ?: "Unknown error" + Log.e(TAG, "Transcription failed: $errorMsg") + onError?.invoke(context.getString(R.string.whisper_error_generic)) } } } catch (e: Exception) { @@ -291,25 +307,6 @@ class WhisperRecognitionManager( } } - /** - * Gets language token based on device locale. - */ - private fun getLanguageToken(): Int { - val locale = context.resources.configuration.locales[0] - val languageCode = locale?.language ?: "auto" - - // Map language codes to Whisper tokens (50259+) - return when (languageCode) { - "en" -> 50259 - "de" -> 50261 - "es" -> 50262 - "fr" -> 50265 - "it" -> 50274 - "pl" -> 50269 - else -> -1 // Auto-detect - } - } - /** * Stops recognition if active. */ @@ -317,7 +314,41 @@ class WhisperRecognitionManager( whisperRecorder?.stop() isRecognizing = false onRecognitionStateChanged?.invoke(false) - Log.d(TAG, "Whisper recognition stopped") + Log.d(TAG, "Whisper ONNX recognition stopped") + } + + /** + * Converts WAV ByteArray to PCM FloatArray. + * Assumes 16-bit PCM, mono, 16kHz sample rate. + */ + private fun convertWavToFloatArray(wavData: ByteArray): FloatArray { + // Skip WAV header (44 bytes for standard WAV file) + val dataStartIdx = 44 + if (wavData.size <= dataStartIdx) { + return FloatArray(0) + } + + // Extract 16-bit PCM samples + val numSamples = (wavData.size - dataStartIdx) / 2 + val floatArray = FloatArray(numSamples) + + var floatIndex = 0 + var byteIndex = dataStartIdx + + while (byteIndex < wavData.size - 1) { + // Convert 16-bit little-endian to float (-1.0 to 1.0) + val loByte = wavData[byteIndex].toInt() and 0xFF + val hiByte = (wavData[byteIndex + 1].toInt() and 0xFF) shl 8 + + val sample = (hiByte or loByte).toShort().toFloat() / 32768.0f + floatArray[floatIndex] = sample + + floatIndex++ + byteIndex += 2 + } + + Log.d(TAG, "Converted WAV ${wavData.size} bytes to ${floatArray.size} float samples") + return floatArray } /** @@ -326,12 +357,9 @@ class WhisperRecognitionManager( fun destroy() { whisperRecorder?.release() whisperRecorder = null - whisperEngine?.deinitialize() - whisperEngine = null WhisperRecordBuffer.clear() isRecognizing = false isProcessing = false - Log.d(TAG, "Whisper recognition manager destroyed") + Log.d(TAG, "Whisper ONNX recognition manager destroyed") } } - diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperRecorder.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperRecorder.kt index 9c1d6ca4..c6412a12 100644 --- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperRecorder.kt +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperRecorder.kt @@ -55,6 +55,7 @@ class WhisperRecorder(private val context: Context) { /** * Initializes Voice Activity Detection (VAD). + * If VAD fails to initialize, falls back to duration-based recording (manual stop after 3 seconds of silence). */ fun initVad() { try { @@ -66,9 +67,11 @@ class WhisperRecorder(private val context: Context) { .setSpeechDurationMs(200) // Minimum speech duration .build() - Log.d(TAG, "VAD initialized") + Log.d(TAG, "✓ VAD initialized successfully") } catch (e: Exception) { - Log.e(TAG, "Error initializing VAD", e) + Log.w(TAG, "⚠️ VAD initialization failed - falling back to duration-based recording: ${e.message}") + vad = null + // Will use fallback: recording stops after MAX_RECORDING_DURATION_MS } } @@ -131,13 +134,16 @@ class WhisperRecorder(private val context: Context) { } /** - * Recording loop that reads audio data and processes it with VAD. + * Recording loop that reads audio data and processes it with VAD (or duration-based fallback). */ private suspend fun recordAudio() { val vadBuffer = ByteArray(VAD_FRAME_SIZE * 2) // 16-bit samples val readBuffer = ByteArray(VAD_FRAME_SIZE * 2) var totalBytesRead = 0 val maxBytes = SAMPLE_RATE * 2 * 30 // 30 seconds at 16kHz, 16-bit + var lastSpeechTime = 0L // For fallback silence detection + + Log.d(TAG, "📻 Recording loop started - VAD available: ${vad != null}") while (isRecording && totalBytesRead < maxBytes) { val bytesRead = audioRecord?.read(readBuffer, 0, readBuffer.size) ?: 0 @@ -147,49 +153,75 @@ class WhisperRecorder(private val context: Context) { audioBuffer.addAll(readBuffer.take(bytesRead).toList()) totalBytesRead += bytesRead - // Process with VAD if we have enough data - if (vad != null && audioBuffer.size >= VAD_FRAME_SIZE * 2) { + // Process with VAD if available + val vadInstance = vad // Local copy to avoid concurrent mutation + if (vadInstance != null && audioBuffer.size >= VAD_FRAME_SIZE * 2) { // Get last VAD_FRAME_SIZE * 2 bytes for VAD analysis val startIdx = audioBuffer.size - VAD_FRAME_SIZE * 2 for (i in 0 until VAD_FRAME_SIZE * 2) { vadBuffer[i] = audioBuffer[startIdx + i] } - val isSpeech = vad?.isSpeech(vadBuffer) ?: false + val isSpeech = vadInstance.isSpeech(vadBuffer) + val currentTime = System.currentTimeMillis() if (isSpeech) { if (!speechDetected) { - Log.d(TAG, "VAD: Speech detected, recording starts") + Log.d(TAG, "✓ VAD: Speech detected") speechDetected = true } + lastSpeechTime = currentTime } else { if (speechDetected) { - Log.d(TAG, "VAD: Silence detected after speech, stopping") - stop() - break + // Check if silence has lasted long enough + if (currentTime - lastSpeechTime >= SILENCE_DURATION_MS) { + Log.d(TAG, "✓ VAD: Silence detected, stopping") + stop() + break + } } } + } else if (vad == null && audioBuffer.size > SAMPLE_RATE * 2 * 2) { + // Fallback: no VAD - use fixed duration + val currentTime = System.currentTimeMillis() + val recordingDuration = currentTime - recordingStartTime + + if (!speechDetected && recordingDuration >= 500) { + speechDetected = true + lastSpeechTime = currentTime + Log.d(TAG, "⚠️ Fallback mode: Recording without VAD (started after 500ms)") + } + + // Stop after 3 seconds of recording (since we have no VAD) + if (speechDetected && recordingDuration >= 3000) { + Log.d(TAG, "⚠️ Fallback: Stopping after 3 seconds (no VAD available)") + stop() + break + } } listener?.onRecording() - // Check max duration + // Check absolute max duration val currentTime = System.currentTimeMillis() val totalDuration = currentTime - recordingStartTime if (totalDuration >= MAX_RECORDING_DURATION_MS) { - Log.d(TAG, "Max recording duration reached") + Log.d(TAG, "✓ Max recording duration reached") stop() break } } else if (bytesRead < 0) { - Log.e(TAG, "Error reading audio: $bytesRead") + Log.e(TAG, "✗ Audio read error: $bytesRead") + listener?.onRecordingError("Audio read error") break } // Small delay to prevent busy loop delay(10) } + + Log.d(TAG, "📻 Recording ended - ${totalBytesRead / (SAMPLE_RATE * 2)}s of audio captured") } /** diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperTranscriber.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperTranscriber.kt new file mode 100644 index 00000000..56bdc004 --- /dev/null +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperTranscriber.kt @@ -0,0 +1,334 @@ +package it.palsoftware.pastiera.inputmethod.whisper + +import android.util.Log +import ai.onnxruntime.* +import java.nio.FloatBuffer +import java.nio.IntBuffer +import kotlin.math.max + +/** + * Transcribes audio using Whisper ONNX models. + * Implements the complete 5-step pipeline from RTranslator: + * 1. Initializer (Audio → Mel-Spectrogram) + * 2. Encoder (Mel → Hidden States) + * 3. Cache Initializer + * 4. Decoder Loop (Token Generation with KV-Cache) + * 5. Detokenizer (Tokens → Text) + */ +class WhisperTranscriber(private val onnxManager: WhisperOnnxManager) { + + private val TAG = "WhisperTranscriber" + + // Token IDs from Whisper spec + private val START_TOKEN_ID = 50258 + private val TRANSCRIBE_TOKEN_ID = 50359 + private val TRANSLATE_TOKEN_ID = 50358 + private val NO_TIMESTAMPS_TOKEN_ID = 50363 + private val EOS_TOKEN_ID = 50257 + private val MAX_TOKENS = 445 + private val MAX_TOKENS_PER_SECOND = 30 + + // Language codes (same as RTranslator) + private val LANGUAGES = arrayOf( + "en", "zh", "de", "es", "ru", "ko", "fr", "ja", "pt", "tr", + "pl", "ca", "nl", "ar", "sv", "it", "id", "hi", "fi", "vi", + "he", "uk", "el", "ms", "cs", "ro", "da", "hu", "ta", "no", + "th", "ur", "hr", "bg", "lt", "la", "mi", "ml", "cy", "sk", + "te", "fa", "lv", "bn", "sr", "az", "sl", "kn", "et", "mk", + "br", "eu", "is", "hy", "ne", "mn", "bs", "kk", "sq", "sw", + "gl", "mr", "pa", "si", "km", "sn", "yo", "so", "af", "oc", + "ka", "be", "tg", "sd", "gu", "am", "yi", "lo", "uz", "fo", + "ht", "ps", "tk", "nn", "mt", "sa", "lb", "my", "bo", "tl", + "mg", "as", "tt", "haw", "ln", "ha", "ba", "jw", "su", "yue" + ) + + suspend fun transcribe( + audioData: FloatArray, + languageCode: String = "de" + ): Result = try { + Log.d(TAG, "Starting transcription for $languageCode (${audioData.size} samples)") + + val env = onnxManager.getOnnxEnv() ?: return Result.failure(Exception("ONNX Environment not initialized")) + val startTime = System.currentTimeMillis() + + // Step 1: Initializer (Audio PCM → Mel-Spectrogram) + Log.d(TAG, "[1/5] Initializer: Audio → Mel-Spectrogram") + val floatBuffer = FloatBuffer.wrap(audioData) + val audioTensor = OnnxTensor.createTensor(env, floatBuffer, longArrayOf(1, audioData.size.toLong())) + + val initOutputs = onnxManager.getInitSession()!!.run( + mapOf("audio_pcm" to audioTensor) + ) + val melSpectrogram = initOutputs.get(0) as OnnxTensor + audioTensor.close() + + // Step 2: Encoder (Mel-Spectrogram → Hidden States) + Log.d(TAG, "[2/5] Encoder: Mel → Hidden States") + val encoderOutputs = onnxManager.getEncoderSession()!!.run( + mapOf("input_features" to melSpectrogram) + ) + val encoderHiddenStates = encoderOutputs.get(0) as OnnxTensor + melSpectrogram.close() + + // Step 3: Cache Initializer + Log.d(TAG, "[3/5] Cache Initializer") + val cacheInitInputs = mapOf("encoder_hidden_states" to encoderHiddenStates) + val cacheInitOutputs = onnxManager.getCacheInitSession()!!.run(cacheInitInputs) + + // Step 4: Decoder Loop (Token Generation) + Log.d(TAG, "[4/5] Decoder Loop: Token Generation") + val tokens = decoderLoop(env, languageCode, encoderHiddenStates, cacheInitOutputs) + + // Step 5: Detokenizer (Tokens → Text) + Log.d(TAG, "[5/5] Detokenizer: Tokens → Text") + val finalText = detokenize(env, tokens) + + val processingTime = System.currentTimeMillis() - startTime + Log.d(TAG, "✅ Transcription complete in ${processingTime}ms") + Log.d(TAG, "Result: '$finalText'") + + // Cleanup + encoderOutputs.close() + encoderHiddenStates.close() + cacheInitOutputs.close() + + Result.success(finalText.trim()) + } catch (e: Exception) { + Log.e(TAG, "Transcription error: ${e.message}", e) + Result.failure(e) + } + + /** + * Executes the decoder loop to generate tokens. + * This is the main iterative part (similar to RTranslator). + */ + private fun decoderLoop( + env: OrtEnvironment, + languageCode: String, + encoderOutput: OnnxTensor, + cacheInitResult: OrtSession.Result + ): IntArray { + val generatedTokens = mutableListOf() + val maxTokens = (max(audioSamplesFromEncoder(encoderOutput), 1) / 16000) * MAX_TOKENS_PER_SECOND + val effectiveMaxTokens = minOf(maxTokens, MAX_TOKENS) + + Log.d(TAG, "Decoder: maxTokens=$effectiveMaxTokens") + + // Get language token ID + val languageID = getLanguageID(languageCode) + Log.d(TAG, "Language: $languageCode → ID: $languageID") + + // Initial tokens + val initialTokens = intArrayOf( + START_TOKEN_ID, + languageID, + TRANSCRIBE_TOKEN_ID, // Not translate, just transcribe + NO_TIMESTAMPS_TOKEN_ID + ) + + var decoderResult: OrtSession.Result? = cacheInitResult + var isFirstIteration = true + var tokenCount = 0 + + while (tokenCount < effectiveMaxTokens) { + var inputToken: Int + var inputIDsTensor: OnnxTensor + + if (tokenCount < 4) { + // Use initial tokens + inputToken = initialTokens[tokenCount] + inputIDsTensor = convertIntToTensor(env, intArrayOf(inputToken)) + } else { + // Use previously generated token + inputToken = generatedTokens.last() + inputIDsTensor = convertIntToTensor(env, intArrayOf(inputToken)) + } + + // Check for EOS + if (inputToken == EOS_TOKEN_ID) { + Log.d(TAG, "EOS token reached at position $tokenCount") + break + } + + // Prepare decoder input + val decoderInputMap = mutableMapOf() + decoderInputMap["input_ids"] = inputIDsTensor + + // Add KV-Cache (from previous iteration or init) + try { + for (i in 0 until 12) { + decoderInputMap["past_key_values.$i.decoder.key"] = + if (isFirstIteration) { + createEmptyTensor(env, longArrayOf(1, 12, 0, 64)) + } else { + decoderResult!!.get("present.$i.decoder.key").get() as OnnxTensor + } + + decoderInputMap["past_key_values.$i.decoder.value"] = + if (isFirstIteration) { + createEmptyTensor(env, longArrayOf(1, 12, 0, 64)) + } else { + decoderResult!!.get("present.$i.decoder.value").get() as OnnxTensor + } + + decoderInputMap["past_key_values.$i.encoder.key"] = + cacheInitResult.get("present.$i.encoder.key").get() as OnnxTensor + + decoderInputMap["past_key_values.$i.encoder.value"] = + cacheInitResult.get("present.$i.encoder.value").get() as OnnxTensor + } + } catch (e: Exception) { + Log.w(TAG, "Error setting KV-Cache at step $tokenCount: ${e.message}") + } + + // Run decoder + val oldResult = decoderResult + decoderResult = onnxManager.getDecoderSession()!!.run(decoderInputMap) + + // Extract logits and get max token + val logits = (decoderResult.get("logits") as OnnxTensor).getValue() as FloatArray + val nextToken = getMaxTokenIndex(logits) + + if (nextToken != EOS_TOKEN_ID) { + generatedTokens.add(nextToken) + } + + // Cleanup + inputIDsTensor.close() + if (oldResult != null && tokenCount > 0) { + try { + oldResult.close() + } catch (e: Exception) { + Log.d(TAG, "Error closing result: ${e.message}") + } + } + + isFirstIteration = false + tokenCount++ + + if (tokenCount % 50 == 0) { + Log.d(TAG, "Decoder progress: $tokenCount tokens") + } + } + + Log.d(TAG, "Decoder finished: $tokenCount iterations, ${generatedTokens.size} tokens") + return generatedTokens.toIntArray() + } + + /** + * Converts int array to tensor for decoder input. + */ + private fun convertIntToTensor(env: OrtEnvironment, values: IntArray): OnnxTensor { + return OnnxTensor.createTensor(env, IntBuffer.wrap(values), longArrayOf(1, 1)) + } + + /** + * Creates empty tensor for KV-Cache. + */ + private fun createEmptyTensor(env: OrtEnvironment, shape: LongArray): OnnxTensor { + val flat_length = shape.fold(1L) { acc, d -> acc * d } + val buffer = java.nio.ByteBuffer.allocateDirect((flat_length * 4).toInt()).asFloatBuffer() + return OnnxTensor.createTensor(env, buffer, shape) + } + + /** + * Gets the index of the maximum value in the logits array. + */ + private fun getMaxTokenIndex(logits: FloatArray): Int { + var maxIdx = 0 + var maxVal = logits[0] + for (i in 1 until logits.size) { + if (logits[i] > maxVal) { + maxVal = logits[i] + maxIdx = i + } + } + return maxIdx + } + + /** + * Estimates audio length from encoder output shape. + */ + private fun audioSamplesFromEncoder(tensor: OnnxTensor): Int { + return try { + val value = tensor.getValue() as Array<*> + when { + value.isNotEmpty() && value[0] is Array<*> -> { + val arr = value[0] as Array<*> + arr.size * 160 // Approximate: each frame = 160 samples + } + else -> 16000 // Default to 1 second + } + } catch (e: Exception) { + 16000 + } + } + + /** + * Detokenizes the generated token sequence to text. + */ + private fun detokenize(env: OrtEnvironment, tokens: IntArray): String { + return try { + Log.d(TAG, "Detokenizing ${tokens.size} tokens") + + val tensorInput = OnnxTensor.createTensor( + env, + IntBuffer.wrap(tokens), + longArrayOf(1, 1, tokens.size.toLong()) + ) + + val detokenizerInputs = mapOf("sequences" to tensorInput) + val detokenizerOutput = onnxManager.getDetokenizerSession()!!.run(detokenizerInputs) + + val textArray = detokenizerOutput.get(0).getValue() as Array> + val rawText = textArray[0][0] + + tensorInput.close() + detokenizerOutput.close() + + correctText(rawText) + } catch (e: Exception) { + Log.e(TAG, "Detokenization error: ${e.message}", e) + "" + } + } + + /** + * Post-processes transcribed text (remove timestamps, capitalize, etc). + */ + private fun correctText(text: String): String { + var corrected = text + + // Remove timestamps like <|0.00|> + corrected = corrected.replace(Regex("<\\|[^>]*\\|> "), "") + + // Trim whitespace + corrected = corrected.trim() + + if (corrected.length >= 2) { + // Capitalize first letter + if (corrected[0].isLowerCase()) { + corrected = corrected[0].uppercaseChar() + corrected.substring(1) + } + + // Remove ellipsis + corrected = corrected.replace("...", "") + } + + return corrected + } + + /** + * Maps language code to Whisper token ID. + */ + private fun getLanguageID(languageCode: String): Int { + val code = languageCode.lowercase() + for (i in LANGUAGES.indices) { + if (LANGUAGES[i] == code) { + return START_TOKEN_ID + i + 1 + } + } + // Default: auto-detect (no language token) + return -1 + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4bc98483..67be4e57 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -582,4 +582,27 @@ Displays trackpad events in a small overlay for debugging purposes + + + Local + OpenAI API + + + Use OpenAI\'s cloud-based Whisper API for superior transcription quality across multiple languages. Requires a valid OpenAI API key. + Use OpenAI API + API Key + sk-... + Validate + API Key is valid! + Model + Language (ISO 639-1) + Prompt Hint (Optional) + Helps the model understand context. e.g., "This is a technical discussion about software development" + Optional: Context to improve transcription accuracy + Processing audio with OpenAI API... + OpenAI API key not configured + Invalid API key. Please check and try again. + Failed to initialize OpenAI client + Transcription failed: %1$s + An error occurred during transcription From da39f66188426924d8c3f463fd7e6f682c70f486 Mon Sep 17 00:00:00 2001 From: Patrick Zauner <74716024+pzauner@users.noreply.github.com> Date: Mon, 5 Jan 2026 17:20:48 +0100 Subject: [PATCH 14/14] feat: Comprehensive Speech Recognition Enhancements - **Onboarding AI Features**: Add AI Features and Voice Input Button toggles to tutorial screen for granular control during setup - **Speech Engine Defaults**: Change default engine to Google Speech Recognition instead of Whisper - **Long-Press Mic Button**: Long-press mic button navigates directly to Speech Recognition Settings - **Error Handling**: Suppress non-critical error toasts (NO_MATCH, SPEECH_TIMEOUT); only show critical errors - **SpeechRecognizer Lifecycle**: Properly destroy and recreate SpeechRecognizer after each recognition session to prevent ERROR_CLIENT - **OpenRouter Audio Filtering**: Filter models to show only those supporting audio input modalities - **OpenRouter Audio Pricing**: - Correctly parse per-token pricing from API and convert to per-million-tokens format - Display audio-specific pricing with proper formatting ($/M audio tokens) - Fallback to prompt pricing if audio pricing unavailable - **Availability Checks**: Show toast when speech recognition unavailable on device - **Voice Input Button Logic**: Display mic button when explicitly enabled, show warning toast when AI Features disabled - **Tests**: Add instrumented tests for OpenRouter audio model filtering, pricing extraction, and label formatting --- .gitignore | 1 + .idea/deploymentTargetSelector.xml | 8 + app/build.gradle.kts | 1 + app/build.properties | 4 +- .../pastiera/ExampleInstrumentedTest.kt | 144 ++++++++++++++++- .../palsoftware/pastiera/SettingsActivity.kt | 9 +- .../palsoftware/pastiera/SettingsManager.kt | 42 ++++- .../it/palsoftware/pastiera/SettingsScreen.kt | 9 +- .../palsoftware/pastiera/TutorialActivity.kt | 145 ++++++++++++++++++ .../pastiera/WhisperSettingsScreen.kt | 74 ++++++++- .../inputmethod/SpeechRecognitionManager.kt | 57 ++++--- .../inputmethod/ui/VariationBarView.kt | 76 +++++++-- .../inputmethod/whisper/AudioDebugHelper.kt | 1 + .../inputmethod/whisper/WavAudioWriter.kt | 1 + app/src/main/res/values/strings.xml | 2 + 15 files changed, 525 insertions(+), 49 deletions(-) diff --git a/.gitignore b/.gitignore index e00bcf69..c0492eba 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ keystore.properties # Build scripts (local) assembla_release.sh +.kotlin/errors/errors-1767625645437.log diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index b268ef36..53b6bd70 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -4,6 +4,14 @@ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5a6ba70c..9212bf56 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -219,6 +219,7 @@ dependencies { testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation("androidx.test:runner:1.5.2") androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.ui.test.junit4) debugImplementation(libs.androidx.ui.tooling) diff --git a/app/build.properties b/app/build.properties index bb966aad..d16bb670 100644 --- a/app/build.properties +++ b/app/build.properties @@ -1,4 +1,4 @@ #Build number and date -#Mon Jan 05 00:51:21 CET 2026 +#Mon Jan 05 17:17:36 CET 2026 buildDate=05 gen 2026 -buildNumber=1947 +buildNumber=1988 diff --git a/app/src/androidTest/java/it/palsoftware/pastiera/ExampleInstrumentedTest.kt b/app/src/androidTest/java/it/palsoftware/pastiera/ExampleInstrumentedTest.kt index 853f0a57..695445b3 100644 --- a/app/src/androidTest/java/it/palsoftware/pastiera/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/it/palsoftware/pastiera/ExampleInstrumentedTest.kt @@ -2,10 +2,8 @@ package it.palsoftware.pastiera import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 - import org.junit.Test import org.junit.runner.RunWith - import org.junit.Assert.* /** @@ -21,4 +19,146 @@ class ExampleInstrumentedTest { val appContext = InstrumentationRegistry.getInstrumentation().targetContext assertEquals("it.palsoftware.pastiera", appContext.packageName) } + + /** + * Test that OpenRouter models are correctly filtered by audio input capability. + * Only models with "audio" in input_modalities should be returned. + */ + @Test + fun testOpenRouterAudioModelFiltering() { + // Simulate real OpenRouter API response data + val modelsThatShouldBeIncluded = listOf( + mapOf( + "id" to "google/gemini-2.0-flash-001", + "name" to "Google Gemini 2.0 Flash", + "input_modalities" to listOf("text", "audio", "image", "video"), + "audio_price" to "0.70" + ), + mapOf( + "id" to "openai/whisper-1", + "name" to "OpenAI Whisper-1", + "input_modalities" to listOf("audio"), + "audio_price" to "0.02" + ) + ) + + val modelsThatShouldBeExcluded = listOf( + mapOf( + "id" to "google/gemini-1.5-pro", + "name" to "Google Gemini 1.5 Pro", + "input_modalities" to listOf("text", "image", "video"), // NO audio! + "audio_price" to "0" + ), + mapOf( + "id" to "text-only/model", + "name" to "Text Only Model", + "input_modalities" to listOf("text"), // NO audio! + "audio_price" to "0" + ) + ) + + // Test filtering logic + fun hasAudioInputSupport(modalities: List): Boolean { + return modalities.contains("audio") + } + + // Verify included models have audio + for (model in modelsThatShouldBeIncluded) { + @Suppress("UNCHECKED_CAST") + val modalities = model["input_modalities"] as List + assertTrue( + "Model ${model["name"]} should have audio support", + hasAudioInputSupport(modalities) + ) + } + + // Verify excluded models don't have audio + for (model in modelsThatShouldBeExcluded) { + @Suppress("UNCHECKED_CAST") + val modalities = model["input_modalities"] as List + assertFalse( + "Model ${model["name"]} should NOT have audio support", + hasAudioInputSupport(modalities) + ) + } + } + + /** + * Test that audio pricing is correctly extracted from OpenRouter response. + * Should use "audio" field, fallback to "prompt" if not available. + */ + @Test + fun testOpenRouterAudioPricingExtraction() { + // Simulate pricing objects from OpenRouter API + val pricingWithAudio = mapOf( + "prompt" to "0.10", + "completion" to "0.40", + "audio" to "0.70" // Audio-specific pricing + ) + + val pricingWithoutAudio = mapOf( + "prompt" to "0.0000075", + "completion" to "0.00003" + // No "audio" field - should fallback to prompt + ) + + val pricingZero = mapOf( + "prompt" to "0", + "completion" to "0" + ) + + // Test extraction logic + fun extractAudioPrice(pricing: Map): String { + var price = pricing["audio"] ?: "" + if (price.isEmpty()) { + price = pricing["prompt"] ?: "0" + } + return price + } + + // Test cases + assertEquals("0.70", extractAudioPrice(pricingWithAudio)) + assertEquals("0.0000075", extractAudioPrice(pricingWithoutAudio)) + assertEquals("0", extractAudioPrice(pricingZero)) + } + + /** + * Test UI label formatting for audio models. + * Display should show: "ModelName • $price/M audio tokens" with 2 decimal places. + */ + @Test + fun testOpenRouterAudioLabelFormatting() { + data class AudioModel( + val name: String, + val audioPrice: String + ) + + val models = listOf( + AudioModel("Google Gemini 2.0 Flash", "0.70"), + AudioModel("OpenAI Whisper-1", "0.02"), + AudioModel("Very Cheap Model", "0.000001"), + AudioModel("Free Model", "0") + ) + + fun formatLabel(name: String, price: String): String { + val priceDouble = price.toDouble() + return if (priceDouble > 0) { + "$name • \$${"%.2f".format(priceDouble)}/M audio tokens" + } else { + name + } + } + + val expectedLabels = listOf( + "Google Gemini 2.0 Flash • \$0.70/M audio tokens", + "OpenAI Whisper-1 • \$0.02/M audio tokens", + "Very Cheap Model • \$0.00/M audio tokens", + "Free Model" + ) + + for (i in models.indices) { + val formatted = formatLabel(models[i].name, models[i].audioPrice) + assertEquals(expectedLabels[i], formatted) + } + } } \ No newline at end of file diff --git a/app/src/main/java/it/palsoftware/pastiera/SettingsActivity.kt b/app/src/main/java/it/palsoftware/pastiera/SettingsActivity.kt index baa27ba7..7170d4db 100644 --- a/app/src/main/java/it/palsoftware/pastiera/SettingsActivity.kt +++ b/app/src/main/java/it/palsoftware/pastiera/SettingsActivity.kt @@ -15,10 +15,17 @@ class SettingsActivity : ComponentActivity() { overridePendingTransition(R.anim.slide_in_from_right, 0) } enableEdgeToEdge() + + val openScreen = intent.getStringExtra("openScreen") + setContent { PastieraTheme { SettingsScreen( - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), + initialDestination = when (openScreen) { + "SpeechRecognition" -> SettingsDestination.SpeechRecognition + else -> null + } ) } } diff --git a/app/src/main/java/it/palsoftware/pastiera/SettingsManager.kt b/app/src/main/java/it/palsoftware/pastiera/SettingsManager.kt index 3a1bbfba..0f9d9ff4 100644 --- a/app/src/main/java/it/palsoftware/pastiera/SettingsManager.kt +++ b/app/src/main/java/it/palsoftware/pastiera/SettingsManager.kt @@ -868,7 +868,7 @@ object SettingsManager { private const val KEY_OPENAI_API_PROMPT = "openai_api_prompt" private const val KEY_OPENAI_API_TEMPERATURE = "openai_api_temperature" - private const val DEFAULT_WHISPER_MODE = "local" // "local" or "api" + private const val DEFAULT_WHISPER_MODE = "google" // "google" (Android native), "local", or "api" private const val DEFAULT_USE_OPENAI_API = false private const val DEFAULT_OPENAI_MODEL = "gpt-4o-transcribe" private const val DEFAULT_OPENAI_API_LANGUAGE = "de" // German by default @@ -882,6 +882,14 @@ object SettingsManager { private const val DEFAULT_OPENROUTER_MODEL = "google/gemini-2.5-flash" private const val DEFAULT_OPENROUTER_LANGUAGE = "de" + + // Voice Input Button Settings + private const val KEY_VOICE_INPUT_BUTTON_ENABLED = "voice_input_button_enabled" + private const val DEFAULT_VOICE_INPUT_BUTTON_ENABLED = true + + // AI Features Settings + private const val KEY_AI_FEATURES_ENABLED = "ai_features_enabled" + private const val DEFAULT_AI_FEATURES_ENABLED = true /** * Gets whether Whisper should be used instead of Google Speech Recognition. @@ -1083,6 +1091,38 @@ object SettingsManager { .apply() } + /** + * Returns whether the voice input button is enabled + */ + fun getVoiceInputButtonEnabled(context: Context): Boolean { + return getPreferences(context).getBoolean(KEY_VOICE_INPUT_BUTTON_ENABLED, DEFAULT_VOICE_INPUT_BUTTON_ENABLED) + } + + /** + * Sets whether the voice input button is enabled + */ + fun setVoiceInputButtonEnabled(context: Context, enabled: Boolean) { + getPreferences(context).edit() + .putBoolean(KEY_VOICE_INPUT_BUTTON_ENABLED, enabled) + .apply() + } + + /** + * Returns whether AI features (voice input, speech recognition) are enabled + */ + fun getAiFeaturesEnabled(context: Context): Boolean { + return getPreferences(context).getBoolean(KEY_AI_FEATURES_ENABLED, DEFAULT_AI_FEATURES_ENABLED) + } + + /** + * Sets whether AI features (voice input, speech recognition) are enabled + */ + fun setAiFeaturesEnabled(context: Context, enabled: Boolean) { + getPreferences(context).edit() + .putBoolean(KEY_AI_FEATURES_ENABLED, enabled) + .apply() + } + /** * Returns true if long press uses Shift, false if it uses Alt. */ diff --git a/app/src/main/java/it/palsoftware/pastiera/SettingsScreen.kt b/app/src/main/java/it/palsoftware/pastiera/SettingsScreen.kt index 60809b7e..9d119f00 100644 --- a/app/src/main/java/it/palsoftware/pastiera/SettingsScreen.kt +++ b/app/src/main/java/it/palsoftware/pastiera/SettingsScreen.kt @@ -66,7 +66,8 @@ sealed class SettingsDestination { */ @Composable fun SettingsScreen( - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + initialDestination: SettingsDestination? = null ) { val context = LocalContext.current val activity = context as? ComponentActivity @@ -74,7 +75,11 @@ fun SettingsScreen( var checkingForUpdates by remember { mutableStateOf(false) } var navigationDirection by remember { mutableStateOf(NavigationDirection.Push) } val navigationStack = remember { - mutableStateListOf(SettingsDestination.Main) + val initialStack = mutableStateListOf(SettingsDestination.Main) + if (initialDestination != null && initialDestination != SettingsDestination.Main) { + initialStack.add(initialDestination) + } + initialStack } val currentDestination by remember { derivedStateOf { navigationStack.last() } diff --git a/app/src/main/java/it/palsoftware/pastiera/TutorialActivity.kt b/app/src/main/java/it/palsoftware/pastiera/TutorialActivity.kt index 4f0ce74d..f9348a55 100644 --- a/app/src/main/java/it/palsoftware/pastiera/TutorialActivity.kt +++ b/app/src/main/java/it/palsoftware/pastiera/TutorialActivity.kt @@ -88,6 +88,11 @@ sealed class TutorialPageType { val title: String, val description: String ) : TutorialPageType() + + data class AiFeatures( + val title: String, + val description: String + ) : TutorialPageType() } @OptIn(ExperimentalFoundationApi::class) @@ -128,6 +133,10 @@ fun TutorialScreen( icon = Icons.Filled.Settings, iconTint = MaterialTheme.colorScheme.primary ), + TutorialPageType.AiFeatures( + title = stringResource(R.string.tutorial_page_ai_features_title), + description = stringResource(R.string.tutorial_page_ai_features_description) + ), TutorialPageType.Standard( title = stringResource(R.string.tutorial_page_ready_title), description = stringResource(R.string.tutorial_page_ready_description), @@ -143,6 +152,9 @@ fun TutorialScreen( var isPastieraEnabled by remember { mutableStateOf(false) } var isPastieraSelected by remember { mutableStateOf(false) } + // AI Features state + var aiFeaturesEnabled by remember { mutableStateOf(SettingsManager.getAiFeaturesEnabled(context)) } + LaunchedEffect(Unit) { checkImeStatus(context) { enabled, selected -> isPastieraEnabled = enabled @@ -232,6 +244,17 @@ fun TutorialScreen( modifier = Modifier.fillMaxSize() ) } + is TutorialPageType.AiFeatures -> { + TutorialAiFeaturesPageContent( + page = pageType, + aiFeaturesEnabled = aiFeaturesEnabled, + onAiFeaturesEnabledChange = { enabled -> + aiFeaturesEnabled = enabled + SettingsManager.setAiFeaturesEnabled(context, enabled) + }, + modifier = Modifier.fillMaxSize() + ) + } } } @@ -698,3 +721,125 @@ private fun checkImeStatus( callback(false, false) } } + +@Composable +private fun TutorialAiFeaturesPageContent( + page: TutorialPageType.AiFeatures, + aiFeaturesEnabled: Boolean, + onAiFeaturesEnabledChange: (Boolean) -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + var voiceInputButtonEnabled by remember { mutableStateOf(SettingsManager.getVoiceInputButtonEnabled(context)) } + + TutorialPageLayout( + title = page.title, + description = page.description, + modifier = modifier, + iconContent = { + Surface( + modifier = Modifier + .size(TutorialIconSurfaceSize) + .clip(androidx.compose.foundation.shape.RoundedCornerShape(16.dp)), + color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.15f) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize() + ) { + Icon( + imageVector = Icons.Filled.Mic, + contentDescription = null, + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier.size(TutorialIconSize) + ) + } + } + }, + content = { + Spacer(modifier = Modifier.height(16.dp)) + + // AI Features Toggle + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = MaterialTheme.shapes.medium + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(end = 8.dp) + ) { + Text( + text = "AI Features (Work in Progress)", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "Master toggle for speech recognition & voice input features", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = aiFeaturesEnabled, + onCheckedChange = onAiFeaturesEnabledChange + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Voice Input Button Toggle + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = MaterialTheme.shapes.medium + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(end = 8.dp) + ) { + Text( + text = "Voice Input Button", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + text = "Show microphone button during typing", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = voiceInputButtonEnabled, + onCheckedChange = { enabled -> + voiceInputButtonEnabled = enabled + SettingsManager.setVoiceInputButtonEnabled(context, enabled) + } + ) + } + } + } + ) +} diff --git a/app/src/main/java/it/palsoftware/pastiera/WhisperSettingsScreen.kt b/app/src/main/java/it/palsoftware/pastiera/WhisperSettingsScreen.kt index faceb467..99445647 100644 --- a/app/src/main/java/it/palsoftware/pastiera/WhisperSettingsScreen.kt +++ b/app/src/main/java/it/palsoftware/pastiera/WhisperSettingsScreen.kt @@ -51,6 +51,9 @@ fun WhisperSettingsScreen( // Engine selection (Meta-Menu) var selectedEngine by remember { mutableStateOf(SettingsManager.getWhisperMode(context)) } + // Voice Input Button + var voiceInputButtonEnabled by remember { mutableStateOf(SettingsManager.getVoiceInputButtonEnabled(context)) } + // OpenRouter settings var openRouterApiKey by remember { mutableStateOf(SettingsManager.getOpenRouterApiKey(context)) } var openRouterModel by remember { mutableStateOf(SettingsManager.getOpenRouterModel(context)) } @@ -134,6 +137,11 @@ fun WhisperSettingsScreen( selectedEngine = engine SettingsManager.setWhisperMode(context, engine) }, + voiceInputButtonEnabled = voiceInputButtonEnabled, + onVoiceInputButtonEnabledChange = { enabled -> + voiceInputButtonEnabled = enabled + SettingsManager.setVoiceInputButtonEnabled(context, enabled) + }, onLocalSettingsClick = { navigateTo(WhisperScreen.Local) }, onOpenAiSettingsClick = { navigateTo(WhisperScreen.OpenAi) }, onOpenRouterSettingsClick = { navigateTo(WhisperScreen.OpenRouter) } @@ -217,6 +225,8 @@ private fun WhisperMainScreen( modifier: Modifier = Modifier, selectedEngine: String, onEngineSelected: (String) -> Unit, + voiceInputButtonEnabled: Boolean, + onVoiceInputButtonEnabledChange: (Boolean) -> Unit, onLocalSettingsClick: () -> Unit, onOpenAiSettingsClick: () -> Unit, onOpenRouterSettingsClick: () -> Unit = {} @@ -230,6 +240,46 @@ private fun WhisperMainScreen( .padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { + // Voice Input Button Option + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = MaterialTheme.shapes.medium + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(end = 12.dp) + ) { + Text( + text = "Voice Input Microphone", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + Text( + text = "Show microphone button below text input", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp) + ) + } + Switch( + checked = voiceInputButtonEnabled, + onCheckedChange = onVoiceInputButtonEnabledChange + ) + } + } + + // Spacer + Spacer(modifier = Modifier.height(8.dp)) + // Info Surface( modifier = Modifier.fillMaxWidth(), @@ -1307,23 +1357,33 @@ private suspend fun fetchOpenRouterModels(apiKey: String): List 0) { - // Convert to per-million tokens for readability - val pricePerMillion = priceDouble * 1_000_000 - "$name • \$${"%.2f".format(pricePerMillion)}/M tokens" + val pricePerToken = audioInputPrice?.toDouble() ?: 0.0 + val pricePerMillionTokens = pricePerToken * 1_000_000 + if (pricePerMillionTokens > 0) { + // Format to 2 decimal places for display + "$name • \$${"%.2f".format(pricePerMillionTokens)}/M audio tokens" } else { name } } catch (e: NumberFormatException) { + Log.e("OpenRouterModels", "Cannot parse price '$audioInputPrice' as Double") name } - Log.d("OpenRouterModels", "✓ Found audio model: $id") + Log.d("OpenRouterModels", "✓ Found audio model: $id with pricing: $audioInputPrice per token") audioModels.add(id to label) } } catch (e: Exception) { diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/SpeechRecognitionManager.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/SpeechRecognitionManager.kt index 6ce958f2..d5725d67 100644 --- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/SpeechRecognitionManager.kt +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/SpeechRecognitionManager.kt @@ -128,6 +128,7 @@ class SpeechRecognitionManager( // Create SpeechRecognizer - let system find the best available service Log.d(TAG, "Creating SpeechRecognizer instance") speechRecognizer = SpeechRecognizer.createSpeechRecognizer(context)?.apply { + Log.d(TAG, "SpeechRecognizer created, now setting listener") setRecognitionListener(object : RecognitionListener { override fun onReadyForSpeech(params: Bundle?) { Log.d(TAG, "Speech recognition ready for speech") @@ -157,14 +158,6 @@ class SpeechRecognitionManager( } override fun onError(error: Int) { - // Notify that recognition is finished (due to error) - onRecognitionStateChanged?.invoke(false) - - // Clear partial text on error - if (isComposingPartialText) { - clearPartialText() - } - val errorMessage = when (error) { SpeechRecognizer.ERROR_AUDIO -> "ERROR_AUDIO - Audio recording error" SpeechRecognizer.ERROR_CLIENT -> "ERROR_CLIENT - Other client side errors" @@ -179,17 +172,37 @@ class SpeechRecognitionManager( } Log.w(TAG, "Speech recognition error: $errorMessage") - // Show user-friendly error message - Handler(Looper.getMainLooper()).post { - val userMessage = when (error) { - SpeechRecognizer.ERROR_NO_MATCH -> context.getString(R.string.speech_recognition_error_no_match) - SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> context.getString(R.string.speech_recognition_error_timeout) - SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> context.getString(R.string.speech_recognition_error_permission) - SpeechRecognizer.ERROR_NETWORK -> context.getString(R.string.speech_recognition_error_network) - else -> context.getString(R.string.speech_recognition_error_generic) + // Only ignore truly non-critical errors (no speech detected) + val isIgnorableError = error in listOf( + SpeechRecognizer.ERROR_NO_MATCH, + SpeechRecognizer.ERROR_SPEECH_TIMEOUT + ) + + Log.d(TAG, "Error is ignorable: $isIgnorableError, showing toast: ${!isIgnorableError}") + + if (!isIgnorableError) { + // Notify that recognition is finished + onRecognitionStateChanged?.invoke(false) + + // Clear partial text on error + if (isComposingPartialText) { + clearPartialText() + } + + // Show user-friendly error message + Handler(Looper.getMainLooper()).post { + val userMessage = when (error) { + SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> context.getString(R.string.speech_recognition_error_permission) + SpeechRecognizer.ERROR_NETWORK -> context.getString(R.string.speech_recognition_error_network) + else -> context.getString(R.string.speech_recognition_error_generic) + } + Log.e(TAG, "Showing error toast: $userMessage") + Toast.makeText(context, userMessage, Toast.LENGTH_SHORT).show() + onError?.invoke(userMessage) } - Toast.makeText(context, userMessage, Toast.LENGTH_SHORT).show() - onError?.invoke(userMessage) + } else { + // For non-critical errors, just log them without showing to user + Log.d(TAG, "Ignorable error, no toast shown: $errorMessage") } } @@ -197,6 +210,13 @@ class SpeechRecognitionManager( // Notify that recognition is finished onRecognitionStateChanged?.invoke(false) + // Destroy and reset SpeechRecognizer for next session + // Set to null FIRST to prevent race conditions if user clicks mic again immediately + val recognizerToDestroy = speechRecognizer + speechRecognizer = null + Log.d(TAG, "SpeechRecognizer set to null, destroying instance to prepare for next session") + recognizerToDestroy?.destroy() + val matches = results.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION) val confidenceScores = results.getFloatArray(SpeechRecognizer.CONFIDENCE_SCORES) @@ -389,6 +409,7 @@ class SpeechRecognitionManager( } Log.d(TAG, "Starting speech recognition with language: $languageTag") + Log.d(TAG, "SpeechRecognizer state before startListening: ${if (speechRecognizer != null) "exists" else "null"}") speechRecognizer?.startListening(intent) Log.d(TAG, "Speech recognition started via SpeechRecognizer") } catch (e: SecurityException) { diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/VariationBarView.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/VariationBarView.kt index a97f5302..044280cf 100644 --- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/VariationBarView.kt +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/VariationBarView.kt @@ -592,24 +592,40 @@ class VariationBarView( val buttonsContainerView = buttonsContainer ?: return buttonsContainerView.removeAllViews() - val microphoneButton = microphoneButtonView ?: createMicrophoneButton(fixedButtonSize) - microphoneButtonView = microphoneButton - (microphoneButton.parent as? ViewGroup)?.removeView(microphoneButton) - val micParams = LinearLayout.LayoutParams(fixedButtonSize, fixedButtonSize).apply { - marginStart = spacingBetweenButtons - } - buttonsContainerView.addView(microphoneButton, micParams) - microphoneButton.setOnClickListener { - NotificationHelper.triggerHapticFeedback(context) - // Use callback if available (modern SpeechRecognizer approach), otherwise fallback to Activity - if (onSpeechRecognitionRequested != null) { - onSpeechRecognitionRequested?.invoke() - } else { - startSpeechRecognition(inputConnection) + // Check if voice input button should be shown + // Show mic button if Voice Input Button is explicitly enabled by user + // (AI Features check happens when button is clicked) + val voiceInputButtonEnabled = SettingsManager.getVoiceInputButtonEnabled(context) + + if (voiceInputButtonEnabled) { + val microphoneButton = microphoneButtonView ?: createMicrophoneButton(fixedButtonSize) + microphoneButtonView = microphoneButton + (microphoneButton.parent as? ViewGroup)?.removeView(microphoneButton) + val micParams = LinearLayout.LayoutParams(fixedButtonSize, fixedButtonSize).apply { + marginStart = spacingBetweenButtons } + buttonsContainerView.addView(microphoneButton, micParams) + microphoneButton.setOnClickListener { + NotificationHelper.triggerHapticFeedback(context) + // Use callback if available (modern SpeechRecognizer approach), otherwise fallback to Activity + if (onSpeechRecognitionRequested != null) { + onSpeechRecognitionRequested?.invoke() + } else { + startSpeechRecognition(inputConnection) + } + } + microphoneButton.setOnLongClickListener { + NotificationHelper.triggerHapticFeedback(context) + // Open Speech Recognition Settings + openSpeechRecognitionSettings() + true + } + microphoneButton.alpha = 1f + microphoneButton.visibility = View.VISIBLE + } else { + removeMicrophoneImmediate() + microphoneButtonView = null } - microphoneButton.alpha = 1f - microphoneButton.visibility = View.VISIBLE // Language switch button (language code) val languageButton = languageButtonView ?: createLanguageButton(fixedButtonSize) @@ -981,6 +997,17 @@ class VariationBarView( } private fun startSpeechRecognition(inputConnection: android.view.inputmethod.InputConnection?) { + // Check if AI Features are enabled + val aiFeaturesEnabled = SettingsManager.getAiFeaturesEnabled(context) + if (!aiFeaturesEnabled) { + android.widget.Toast.makeText( + context, + "AI Features are disabled. Enable them in Settings → Speech Recognition to use voice input.", + android.widget.Toast.LENGTH_LONG + ).show() + return + } + try { val intent = Intent(context, SpeechRecognitionActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or @@ -991,6 +1018,11 @@ class VariationBarView( Log.d(TAG, "Speech recognition started") } catch (e: Exception) { Log.e(TAG, "Unable to launch speech recognition", e) + android.widget.Toast.makeText( + context, + "Speech recognition is not available on this device.", + android.widget.Toast.LENGTH_SHORT + ).show() } } @@ -1005,6 +1037,18 @@ class VariationBarView( } } + private fun openSpeechRecognitionSettings() { + try { + val intent = Intent(context, SettingsActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + putExtra("openScreen", "SpeechRecognition") + } + context.startActivity(intent) + } catch (e: Exception) { + Log.e(TAG, "Error opening Speech Recognition Settings", e) + } + } + private fun cancelLongPress() { longPressRunnable?.let { runnable -> longPressHandler?.removeCallbacks(runnable) diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/AudioDebugHelper.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/AudioDebugHelper.kt index 13902553..8c375045 100644 --- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/AudioDebugHelper.kt +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/AudioDebugHelper.kt @@ -101,3 +101,4 @@ object AudioDebugHelper { } + diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WavAudioWriter.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WavAudioWriter.kt index a018fb4c..295ef0f4 100644 --- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WavAudioWriter.kt +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WavAudioWriter.kt @@ -150,3 +150,4 @@ object WavAudioWriter { } + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 67be4e57..57b654ee 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -464,6 +464,8 @@ Nav Mode lets you navigate the device UI with letter shortcuts such as IJKL or ESDF.\nTo activate:\n• Press Ctrl twice quickly\n• Letter keys become navigation controls\n• Press Ctrl again to exit\nEvery shortcut can be remapped in Advanced Settings. Customization Pastiera offers many customization options:\n• Customizable keyboard layouts\n• Launcher shortcuts & Power Shortcuts\n• IME bar can act as a swipe bar to move the cursor\n• Custom mappings for Nav Mode\nExplore the settings to find the perfect configuration for you! + AI & Voice Features + Pastiera includes optional AI features like voice input and speech recognition. You can enable or disable these features at any time in the settings. Ready to Start You are now ready to use Pastiera! Remember that you can always return to settings to further customize the keyboard to your needs. Enable Pastiera