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/.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/.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 dfcf3b36..9212bf56 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -209,9 +209,17 @@ 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")
+ // 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)
+ 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 a53387c4..d16bb670 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
+#Mon Jan 05 17:17:36 CET 2026
+buildDate=05 gen 2026
+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/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/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 83338236..0f9d9ff4 100644
--- a/app/src/main/java/it/palsoftware/pastiera/SettingsManager.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/SettingsManager.kt
@@ -849,6 +849,279 @@ 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 = "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 = "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
+ 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"
+
+ // 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.
+ */
+ 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.SMALL
+ }
+ }
+
+ /**
+ * 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()
+ }
+
+ /**
+ * 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 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 9bd74bef..9d119f00 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()
@@ -64,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
@@ -72,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() }
@@ -145,6 +152,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 +178,12 @@ fun SettingsScreen(
onBack = { navigateBack() }
)
}
+ is SettingsDestination.SpeechRecognition -> {
+ WhisperSettingsScreen(
+ modifier = modifier,
+ onBack = { navigateBack() }
+ )
+ }
is SettingsDestination.Customization -> {
CustomizationSettingsScreen(
modifier = modifier,
@@ -212,6 +226,7 @@ private fun SettingsMainScreen(
onKeyboardTimingClick: () -> Unit,
onTextInputClick: () -> Unit,
onAutoCorrectionClick: () -> Unit,
+ onSpeechRecognitionClick: () -> Unit,
onCustomizationClick: () -> Unit,
onAdvancedClick: () -> Unit,
onAboutClick: () -> Unit,
@@ -261,263 +276,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 +635,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/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
new file mode 100644
index 00000000..99445647
--- /dev/null
+++ b/app/src/main/java/it/palsoftware/pastiera/WhisperSettingsScreen.kt
@@ -0,0 +1,1418 @@
+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
+fun WhisperSettingsScreen(
+ modifier: Modifier = Modifier,
+ onBack: () -> Unit
+) {
+ 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)) }
+
+ // 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)) }
+ 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
+ LaunchedEffect(Unit) {
+ 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(
+ 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 = { navigateBack() }) {
+ 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)
+ ) {
+ // Screen Content
+ when (currentScreen) {
+ WhisperScreen.Main -> WhisperMainScreen(
+ modifier = Modifier.fillMaxSize(),
+ selectedEngine = selectedEngine,
+ onEngineSelected = { engine ->
+ 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) }
+ )
+
+ 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,
+ voiceInputButtonEnabled: Boolean,
+ onVoiceInputButtonEnabledChange: (Boolean) -> 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)
+ ) {
+ // 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(),
+ 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()
+ .padding(vertical = 8.dp),
+ onClick = {
+ if (isDownloaded) {
+ onModelSelected(model)
+ }
+ }
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ RadioButton(
+ selected = isSelected,
+ onClick = {
+ if (isDownloaded) {
+ onModelSelected(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
+ )
+ } 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"
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@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
+ )
+ )
+
+ // 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)
+ )
+
+ // 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(top = 8.dp),
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ shape = MaterialTheme.shapes.small
+ ) {
+ 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(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ RadioButton(
+ selected = model == modelId,
+ onClick = { onModelChange(modelId) }
+ )
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = modelLabel.split(" • ")[0],
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium
+ )
+ if (modelLabel.contains(" • ")) {
+ Text(
+ text = modelLabel.split(" • ", limit = 2)[1],
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ 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")
+
+ // Check for audio input token pricing first
+ var audioInputPrice = pricing?.optString("audio", "")
+
+ // Fallback to regular input tokens (prompt) if no audio-specific pricing
+ if (audioInputPrice.isNullOrEmpty()) {
+ audioInputPrice = pricing?.optString("prompt", "0") ?: "0"
+ }
+
+ // Format label with model name and pricing
+ // Note: OpenRouter API returns prices per token (e.g., "0.0000007")
+ // We need to multiply by 1,000,000 to get price per million tokens
+ val label = try {
+ 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 with pricing: $audioInputPrice per token")
+ 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 bb122c24..8a78b0df 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,9 @@ 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
@@ -273,48 +276,177 @@ 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()
+ // Get selected speech recognition engine
+ val whisperMode = it.palsoftware.pastiera.SettingsManager.getWhisperMode(this)
+
+ // 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 ->
- // Update microphone button based on audio level
- uiHandler.post {
- candidatesBarController.updateMicrophoneAudioLevel(rmsdB)
- }
+ )
}
- )
+ whisperRecognitionManager?.startRecognition()
+ }
+ "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)
+ }
+ }
+ )
+ }
+ 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)
+ }
+ }
+ )
+ }
+ 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()
}
/**
@@ -322,6 +454,9 @@ class PhysicalKeyboardInputMethodService : InputMethodService() {
*/
private fun stopSpeechRecognition() {
speechRecognitionManager?.stopRecognition()
+ whisperRecognitionManager?.stopRecognition()
+ openAiRecognitionManager?.stopRecognition()
+ openRouterRecognitionManager?.stopRecognition()
}
private fun getSuggestionSettings(): SuggestionSettings {
@@ -1122,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/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
new file mode 100644
index 00000000..8c375045
--- /dev/null
+++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/AudioDebugHelper.kt
@@ -0,0 +1,104 @@
+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..295ef0f4
--- /dev/null
+++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WavAudioWriter.kt
@@ -0,0 +1,153 @@
+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/WhisperEngine.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperEngine.kt
new file mode 100644
index 00000000..02dd320e
--- /dev/null
+++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperEngine.kt
@@ -0,0 +1,291 @@
+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)
+
+ 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())
+ adjustedMel.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..9cdaf55e
--- /dev/null
+++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperModel.kt
@@ -0,0 +1,66 @@
+package it.palsoftware.pastiera.inputmethod.whisper
+
+/**
+ * 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,
+ val fileName: String,
+ val sizeBytes: Long,
+ val isMultilingual: Boolean,
+ val description: String
+) {
+ SMALL(
+ displayName = "Small (Multilingual) - Recommended",
+ fileName = "whisper_small_int8.zip",
+ sizeBytes = 243 * 1024 * 1024, // 243 MB
+ isMultilingual = true,
+ description = "Excellent quality, INT8 quantized, supports 99 languages"
+ );
+
+ companion object {
+ fun fromFileName(fileName: String): WhisperModel? {
+ return values().firstOrNull { it.fileName == fileName }
+ }
+ }
+}
+
+/**
+ * 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/whisperOnnx/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,
+ 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
new file mode 100644
index 00000000..129181a9
--- /dev/null
+++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperModelDownloader.kt
@@ -0,0 +1,314 @@
+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
+import java.util.zip.ZipInputStream
+
+/**
+ * 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 and extracted.
+ * For DocWolle ONNX models: checks for ONNX files in the models directory.
+ */
+ fun isModelDownloaded(model: WhisperModel): Boolean {
+ 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
+ }
+
+ /**
+ * 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 total size of downloaded ONNX model files.
+ */
+ fun getDownloadedSize(model: WhisperModel): Long {
+ 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() }
+ }
+
+ /**
+ * 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")
+
+ // 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)
+ } 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?
+ ) {
+ Log.d(TAG, "Attempting to download from: $url")
+ Log.d(TAG, "Target file: ${targetFile.absolutePath}")
+
+ val request = Request.Builder()
+ .url(url)
+ .build()
+
+ 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
+ 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")
+ 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()
+ val errorMsg = "Download incomplete: ${tempFile.length()} of $contentLength bytes"
+ Log.e(TAG, errorMsg)
+ throw Exception(errorMsg)
+ }
+
+ // 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)
+ }
+ } 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)
+ }
+ }
+
+ /**
+ * 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.
+ */
+ 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/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
new file mode 100644
index 00000000..9e558ce1
--- /dev/null
+++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperRecognitionManager.kt
@@ -0,0 +1,365 @@
+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 using ONNX Runtime.
+ * Uses DocWolle's optimized ONNX models from HuggingFace.
+ */
+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 = "WhisperOnnxRecognitionMgr"
+ }
+
+ 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 ONNX models are available.
+ */
+ fun isAvailable(): Boolean {
+ // Check if models are downloaded
+ val selectedModel = SettingsManager.getWhisperModel(context)
+ val downloader = WhisperModelDownloader(context)
+ return downloader.isModelDownloaded(selectedModel)
+ }
+
+ /**
+ * 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 ONNX Manager and Transcriber.
+ */
+ private fun ensureOnnxManager(): Boolean {
+ if (onnxManager != null && transcriber != null) {
+ return true
+ }
+
+ try {
+ // 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
+ }
+
+ 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
+ }
+
+ transcriber = WhisperTranscriber(onnxManager!!)
+ Log.d(TAG, "ONNX Manager and Transcriber initialized")
+ return true
+ } catch (e: Exception) {
+ 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.
+ */
+ 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 ONNX Manager is initialized
+ if (!ensureOnnxManager()) {
+ 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() {
+ onAudioLevelChanged?.invoke(10f)
+ }
+
+ override fun onRecordingDone() {
+ Log.d(TAG, "Recording done, starting ONNX 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 ONNX recognition started")
+ } catch (e: Exception) {
+ Log.e(TAG, "Error starting Whisper ONNX 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.Default).launch {
+ try {
+ // 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?.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 {
+ 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) {
+ 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))
+ }
+ }
+ }
+ }
+
+ /**
+ * Stops recognition if active.
+ */
+ fun stopRecognition() {
+ whisperRecorder?.stop()
+ isRecognizing = false
+ onRecognitionStateChanged?.invoke(false)
+ 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
+ }
+
+ /**
+ * Releases all resources.
+ */
+ fun destroy() {
+ whisperRecorder?.release()
+ whisperRecorder = null
+ WhisperRecordBuffer.clear()
+ isRecognizing = false
+ isProcessing = false
+ Log.d(TAG, "Whisper ONNX 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..cf3c70d4
--- /dev/null
+++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperRecordBuffer.kt
@@ -0,0 +1,58 @@
+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: ByteArray? = null
+
+ /**
+ * Sets the output buffer with PCM_16BIT samples as ByteArray.
+ */
+ fun setOutputBuffer(buffer: ByteArray) {
+ outputBuffer = buffer
+ }
+
+ /**
+ * Gets the output buffer as ByteArray.
+ */
+ fun getOutputBuffer(): ByteArray? {
+ return outputBuffer
+ }
+
+ /**
+ * Gets samples as FloatArray for Whisper processing (normalized to [-1.0, 1.0]).
+ */
+ fun getSamples(): FloatArray? {
+ val bytes = outputBuffer ?: return null
+ return convertPCM16ToFloat(bytes)
+ }
+
+ /**
+ * Clears the buffer.
+ */
+ fun clear() {
+ outputBuffer = null
+ }
+
+ /**
+ * Converts PCM16 ByteArray to FloatArray (normalized to [-1.0, 1.0]).
+ */
+ 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) {
+ // 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..c6412a12
--- /dev/null
+++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/whisper/WhisperRecorder.kt
@@ -0,0 +1,279 @@
+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.VadWebRTC
+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.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 = 800L // Stop after 800ms of silence (like Whisper+)
+ private const val VAD_FRAME_SIZE = 480 // 30ms at 16kHz
+ }
+
+ 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: VadWebRTC? = null
+ private var listener: RecorderListener? = null
+ private var isRecording = false
+ private val audioBuffer = mutableListOf()
+ private var speechDetected = false
+ private var recordingStartTime = 0L
+
+ fun setListener(listener: RecorderListener) {
+ this.listener = listener
+ }
+
+ /**
+ * 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 {
+ vad = Vad.builder()
+ .setSampleRate(SampleRate.SAMPLE_RATE_16K)
+ .setFrameSize(FrameSize.FRAME_SIZE_480) // 30ms frames
+ .setMode(Mode.VERY_AGGRESSIVE) // Aggressive mode like Whisper+
+ .setSilenceDurationMs(SILENCE_DURATION_MS.toInt())
+ .setSpeechDurationMs(200) // Minimum speech duration
+ .build()
+
+ Log.d(TAG, "✓ VAD initialized successfully")
+ } catch (e: Exception) {
+ 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
+ }
+ }
+
+ /**
+ * 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
+ }
+
+ val finalBufferSize = maxOf(bufferSize, VAD_FRAME_SIZE * 2)
+
+ audioRecord = AudioRecord(
+ MediaRecorder.AudioSource.VOICE_RECOGNITION,
+ SAMPLE_RATE,
+ CHANNEL_CONFIG,
+ AUDIO_FORMAT,
+ finalBufferSize * 2 // Double buffer for safety
+ )
+
+ if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) {
+ listener?.onRecordingError("AudioRecord not initialized")
+ return
+ }
+
+ audioBuffer.clear()
+ speechDetected = false
+ audioRecord?.startRecording()
+ isRecording = true
+ recordingStartTime = System.currentTimeMillis()
+ 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 (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
+
+ if (bytesRead > 0) {
+ // Add to buffer
+ audioBuffer.addAll(readBuffer.take(bytesRead).toList())
+ totalBytesRead += bytesRead
+
+ // 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 = vadInstance.isSpeech(vadBuffer)
+ val currentTime = System.currentTimeMillis()
+
+ if (isSpeech) {
+ if (!speechDetected) {
+ Log.d(TAG, "✓ VAD: Speech detected")
+ speechDetected = true
+ }
+ lastSpeechTime = currentTime
+ } else {
+ if (speechDetected) {
+ // 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 absolute max duration
+ val currentTime = System.currentTimeMillis()
+ val totalDuration = currentTime - recordingStartTime
+
+ if (totalDuration >= MAX_RECORDING_DURATION_MS) {
+ Log.d(TAG, "✓ Max recording duration reached")
+ stop()
+ break
+ }
+ } else if (bytesRead < 0) {
+ 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")
+ }
+
+ /**
+ * 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?.close()
+ vad = null
+
+ // 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} bytes (${audioBuffer.size / (SAMPLE_RATE * 2)}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/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/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..9e168b49 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
@@ -300,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 e2f075d2..57b654ee 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
@@ -370,6 +390,7 @@
Smart Features
Auto-correction
Customization
+ Speech Recognition
Advanced
@@ -443,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
@@ -561,4 +584,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
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") }
}
}