From 76a3e1b0ef9300b54d1664327b8bc1a7ffc05e03 Mon Sep 17 00:00:00 2001 From: Hansong Zhang Date: Fri, 20 Feb 2026 15:40:51 -0800 Subject: [PATCH 1/3] Extract shared ASR module from Whisper and Parakeet apps Move ModelSettings, ModelSettingsViewModel, ModelSettingsScreen, FileSelectionRow, FileSelectionDialog, and AsrTheme into a shared Android library at asr/android/shared/. Both apps now consume this via Gradle composite builds (includeBuild). Key design decisions: - ModelSettings uses Whisper's superset (includes preprocessorPath) - DEFAULT_DIRECTORY removed from companion; each app passes its own default directory to the ViewModel via initialize() - ModelSettingsViewModel takes supportsPreprocessor flag to control whether preprocessor files are scanned - ModelSettingsScreen takes showPreprocessor param to conditionally show the preprocessor selection UI - AsrTheme replaces WhisperAppTheme and ParakeetAppTheme - Tests merged (13 ModelSettings tests, 8 ViewModel tests) --- asr/android/shared/build.gradle.kts | 6 + asr/android/shared/gradle/libs.versions.toml | 33 ++ .../gradle/wrapper/gradle-wrapper.properties | 6 + asr/android/shared/lib/build.gradle.kts | 35 ++ .../shared/lib/src/main/AndroidManifest.xml | 2 + .../java/com/example/asr}/ModelSettings.kt | 9 +- .../com/example/asr}/ModelSettingsScreen.kt | 87 ++--- .../example/asr}/ModelSettingsViewModel.kt | 31 +- .../java/com/example/asr/ui/theme/AsrTheme.kt | 4 +- .../com/example/asr}/ModelSettingsTest.kt | 43 +-- .../asr}/ModelSettingsViewModelTest.kt | 3 +- asr/android/shared/settings.gradle.kts | 23 ++ .../android/ParakeetApp/app/build.gradle.kts | 1 + .../com/example/parakeetapp/MainActivity.kt | 19 +- .../com/example/parakeetapp/ModelSettings.kt | 29 -- .../parakeetapp/ModelSettingsScreen.kt | 306 ------------------ .../parakeetapp/ModelSettingsViewModel.kt | 118 ------- .../com/example/parakeetapp/ui/theme/Theme.kt | 31 -- .../example/parakeetapp/ModelSettingsTest.kt | 89 ----- .../parakeetapp/ModelSettingsViewModelTest.kt | 134 -------- .../android/ParakeetApp/settings.gradle.kts | 1 + .../android/WhisperApp/app/build.gradle.kts | 1 + .../com/example/whisperapp/MainActivity.kt | 22 +- .../android/WhisperApp/settings.gradle.kts | 1 + 24 files changed, 242 insertions(+), 792 deletions(-) create mode 100644 asr/android/shared/build.gradle.kts create mode 100644 asr/android/shared/gradle/libs.versions.toml create mode 100644 asr/android/shared/gradle/wrapper/gradle-wrapper.properties create mode 100644 asr/android/shared/lib/build.gradle.kts create mode 100644 asr/android/shared/lib/src/main/AndroidManifest.xml rename {whisper/android/WhisperApp/app/src/main/java/com/example/whisperapp => asr/android/shared/lib/src/main/java/com/example/asr}/ModelSettings.kt (78%) rename {whisper/android/WhisperApp/app/src/main/java/com/example/whisperapp => asr/android/shared/lib/src/main/java/com/example/asr}/ModelSettingsScreen.kt (82%) rename {whisper/android/WhisperApp/app/src/main/java/com/example/whisperapp => asr/android/shared/lib/src/main/java/com/example/asr}/ModelSettingsViewModel.kt (75%) rename whisper/android/WhisperApp/app/src/main/java/com/example/whisperapp/ui/theme/Theme.kt => asr/android/shared/lib/src/main/java/com/example/asr/ui/theme/AsrTheme.kt (93%) rename {whisper/android/WhisperApp/app/src/test/java/com/example/whisperapp => asr/android/shared/lib/src/test/java/com/example/asr}/ModelSettingsTest.kt (71%) rename {whisper/android/WhisperApp/app/src/test/java/com/example/whisperapp => asr/android/shared/lib/src/test/java/com/example/asr}/ModelSettingsViewModelTest.kt (98%) create mode 100644 asr/android/shared/settings.gradle.kts delete mode 100644 parakeet/android/ParakeetApp/app/src/main/java/com/example/parakeetapp/ModelSettings.kt delete mode 100644 parakeet/android/ParakeetApp/app/src/main/java/com/example/parakeetapp/ModelSettingsScreen.kt delete mode 100644 parakeet/android/ParakeetApp/app/src/main/java/com/example/parakeetapp/ModelSettingsViewModel.kt delete mode 100644 parakeet/android/ParakeetApp/app/src/main/java/com/example/parakeetapp/ui/theme/Theme.kt delete mode 100644 parakeet/android/ParakeetApp/app/src/test/java/com/example/parakeetapp/ModelSettingsTest.kt delete mode 100644 parakeet/android/ParakeetApp/app/src/test/java/com/example/parakeetapp/ModelSettingsViewModelTest.kt diff --git a/asr/android/shared/build.gradle.kts b/asr/android/shared/build.gradle.kts new file mode 100644 index 00000000..13e810ac --- /dev/null +++ b/asr/android/shared/build.gradle.kts @@ -0,0 +1,6 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.compose) apply false +} diff --git a/asr/android/shared/gradle/libs.versions.toml b/asr/android/shared/gradle/libs.versions.toml new file mode 100644 index 00000000..447280ef --- /dev/null +++ b/asr/android/shared/gradle/libs.versions.toml @@ -0,0 +1,33 @@ +[versions] +agp = "8.9.0" +kotlin = "2.0.21" +coreKtx = "1.16.0" +junit = "4.13.2" +junitVersion = "1.3.0" +espressoCore = "3.7.0" +lifecycleRuntimeKtx = "2.9.2" +activityCompose = "1.10.1" +composeBom = "2024.09.00" +appcompat = "1.7.1" + +[libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } + +[plugins] +android-library = { id = "com.android.library", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } diff --git a/asr/android/shared/gradle/wrapper/gradle-wrapper.properties b/asr/android/shared/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..3324d4d6 --- /dev/null +++ b/asr/android/shared/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Fri Aug 08 17:07:04 PDT 2025 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/asr/android/shared/lib/build.gradle.kts b/asr/android/shared/lib/build.gradle.kts new file mode 100644 index 00000000..55912758 --- /dev/null +++ b/asr/android/shared/lib/build.gradle.kts @@ -0,0 +1,35 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.example.asr" + compileSdk = 35 + + defaultConfig { + minSdk = 24 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + buildFeatures { + compose = true + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.material3) + testImplementation(libs.junit) +} diff --git a/asr/android/shared/lib/src/main/AndroidManifest.xml b/asr/android/shared/lib/src/main/AndroidManifest.xml new file mode 100644 index 00000000..8072ee00 --- /dev/null +++ b/asr/android/shared/lib/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/whisper/android/WhisperApp/app/src/main/java/com/example/whisperapp/ModelSettings.kt b/asr/android/shared/lib/src/main/java/com/example/asr/ModelSettings.kt similarity index 78% rename from whisper/android/WhisperApp/app/src/main/java/com/example/whisperapp/ModelSettings.kt rename to asr/android/shared/lib/src/main/java/com/example/asr/ModelSettings.kt index 74c6e816..e0ce02f4 100644 --- a/whisper/android/WhisperApp/app/src/main/java/com/example/whisperapp/ModelSettings.kt +++ b/asr/android/shared/lib/src/main/java/com/example/asr/ModelSettings.kt @@ -1,10 +1,10 @@ -package com.example.whisperapp +package com.example.asr /** - * Data class representing the model file settings for Whisper inference. + * Data class representing the model file settings for ASR inference. * - * @param modelPath Path to the main Whisper model (.pte file) - * @param tokenizerPath Path to the tokenizer file (.json or .bin file) + * @param modelPath Path to the main model (.pte file) + * @param tokenizerPath Path to the tokenizer file (.json, .bin, or .model file) * @param preprocessorPath Optional path to the preprocessor model (.pte file). * If empty, raw WAV audio will be used directly. * @param dataPath Optional path to external data file (.ptd file) @@ -32,7 +32,6 @@ data class ModelSettings( } companion object { - const val DEFAULT_DIRECTORY = "/data/local/tmp/whisper" val MODEL_EXTENSIONS = arrayOf(".pte") val TOKENIZER_EXTENSIONS = arrayOf(".json", ".bin", ".model") val DATA_EXTENSIONS = arrayOf(".ptd") diff --git a/whisper/android/WhisperApp/app/src/main/java/com/example/whisperapp/ModelSettingsScreen.kt b/asr/android/shared/lib/src/main/java/com/example/asr/ModelSettingsScreen.kt similarity index 82% rename from whisper/android/WhisperApp/app/src/main/java/com/example/whisperapp/ModelSettingsScreen.kt rename to asr/android/shared/lib/src/main/java/com/example/asr/ModelSettingsScreen.kt index 0f582b26..536ee6b7 100644 --- a/whisper/android/WhisperApp/app/src/main/java/com/example/whisperapp/ModelSettingsScreen.kt +++ b/asr/android/shared/lib/src/main/java/com/example/asr/ModelSettingsScreen.kt @@ -1,4 +1,4 @@ -package com.example.whisperapp +package com.example.asr import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -12,18 +12,24 @@ import androidx.compose.ui.unit.dp /** * Settings screen for selecting model files. + * + * @param showPreprocessor Whether to show the preprocessor file selection row. + * Set to true for Whisper, false for Parakeet. */ @Composable fun ModelSettingsScreen( viewModel: ModelSettingsViewModel, onBackClick: () -> Unit, - onDownloadClick: () -> Unit + onDownloadClick: () -> Unit, + showPreprocessor: Boolean = false ) { var showModelDialog by remember { mutableStateOf(false) } var showTokenizerDialog by remember { mutableStateOf(false) } var showPreprocessorDialog by remember { mutableStateOf(false) } var showDataDialog by remember { mutableStateOf(false) } + val defaultDirectory = viewModel.defaultDirectory + Column( modifier = Modifier .fillMaxSize() @@ -63,21 +69,23 @@ fun ModelSettingsScreen( Spacer(modifier = Modifier.height(16.dp)) - // Preprocessor file selection (optional) - FileSelectionRow( - label = "Preprocessor (.pte) - Optional", - selectedPath = viewModel.modelSettings.preprocessorPath, - required = false, - onClick = { - viewModel.refreshFileLists() - showPreprocessorDialog = true - }, - onClear = if (viewModel.modelSettings.hasPreprocessor()) { - { viewModel.clearPreprocessor() } - } else null - ) + // Preprocessor file selection (optional, Whisper only) + if (showPreprocessor) { + FileSelectionRow( + label = "Preprocessor (.pte) - Optional", + selectedPath = viewModel.modelSettings.preprocessorPath, + required = false, + onClick = { + viewModel.refreshFileLists() + showPreprocessorDialog = true + }, + onClear = if (viewModel.modelSettings.hasPreprocessor()) { + { viewModel.clearPreprocessor() } + } else null + ) - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(16.dp)) + } // Data file selection (optional) FileSelectionRow( @@ -108,13 +116,13 @@ fun ModelSettingsScreen( // Status indicator if (viewModel.isReadyForInference()) { Text( - text = "✓ Ready for inference", + text = "\u2713 Ready for inference", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary ) } else { Text( - text = "⚠ Model and Tokenizer are required", + text = "\u26A0 Model and Tokenizer are required", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error ) @@ -133,25 +141,27 @@ fun ModelSettingsScreen( // Info text Spacer(modifier = Modifier.height(16.dp)) Text( - text = "Scanning: ${ModelSettings.DEFAULT_DIRECTORY} and app storage", + text = "Scanning: $defaultDirectory and app storage", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) - if (viewModel.modelSettings.hasPreprocessor()) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Preprocessor will convert WAV to mel-spectrogram", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } else { - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "No preprocessor: WAV audio will be used directly", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + if (showPreprocessor) { + if (viewModel.modelSettings.hasPreprocessor()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Preprocessor will convert WAV to mel-spectrogram", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "No preprocessor: WAV audio will be used directly", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } } @@ -161,6 +171,7 @@ fun ModelSettingsScreen( title = "Select Model File", files = viewModel.availableModels, currentSelection = viewModel.modelSettings.modelPath, + defaultDirectory = defaultDirectory, onDismiss = { showModelDialog = false }, onSelect = { viewModel.selectModel(it) @@ -174,6 +185,7 @@ fun ModelSettingsScreen( title = "Select Tokenizer File", files = viewModel.availableTokenizers, currentSelection = viewModel.modelSettings.tokenizerPath, + defaultDirectory = defaultDirectory, onDismiss = { showTokenizerDialog = false }, onSelect = { viewModel.selectTokenizer(it) @@ -182,11 +194,12 @@ fun ModelSettingsScreen( ) } - if (showPreprocessorDialog) { + if (showPreprocessor && showPreprocessorDialog) { FileSelectionDialog( title = "Select Preprocessor (Optional)", files = viewModel.availablePreprocessors, currentSelection = viewModel.modelSettings.preprocessorPath, + defaultDirectory = defaultDirectory, onDismiss = { showPreprocessorDialog = false }, onSelect = { viewModel.selectPreprocessor(it) @@ -201,6 +214,7 @@ fun ModelSettingsScreen( title = "Select Data File (Optional)", files = viewModel.availableDataFiles, currentSelection = viewModel.modelSettings.dataPath, + defaultDirectory = defaultDirectory, onDismiss = { showDataDialog = false }, onSelect = { viewModel.selectDataFile(it) @@ -278,6 +292,7 @@ fun FileSelectionDialog( title: String, files: List, currentSelection: String, + defaultDirectory: String, onDismiss: () -> Unit, onSelect: (String) -> Unit, allowNone: Boolean = false @@ -288,10 +303,10 @@ fun FileSelectionDialog( text = { if (files.isEmpty()) { Column { - Text("No files found. Download from the setup screen or use adb push to ${ModelSettings.DEFAULT_DIRECTORY}") + Text("No files found. Download from the setup screen or use adb push to $defaultDirectory") Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Use adb to push files:\nadb push ${ModelSettings.DEFAULT_DIRECTORY}/", + text = "Use adb to push files:\nadb push $defaultDirectory/", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/whisper/android/WhisperApp/app/src/main/java/com/example/whisperapp/ModelSettingsViewModel.kt b/asr/android/shared/lib/src/main/java/com/example/asr/ModelSettingsViewModel.kt similarity index 75% rename from whisper/android/WhisperApp/app/src/main/java/com/example/whisperapp/ModelSettingsViewModel.kt rename to asr/android/shared/lib/src/main/java/com/example/asr/ModelSettingsViewModel.kt index 46ef6209..b74c90c9 100644 --- a/whisper/android/WhisperApp/app/src/main/java/com/example/whisperapp/ModelSettingsViewModel.kt +++ b/asr/android/shared/lib/src/main/java/com/example/asr/ModelSettingsViewModel.kt @@ -1,4 +1,4 @@ -package com.example.whisperapp +package com.example.asr import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -33,20 +33,33 @@ class ModelSettingsViewModel : ViewModel() { var errorMessage by mutableStateOf(null) private set + var defaultDirectory: String = "" + private set + + private var supportsPreprocessor: Boolean = false private var appStorageDirectory: String? = null /** - * Initialize the ViewModel by scanning for available files. + * Initialize the ViewModel with app-specific configuration. + * + * @param defaultDirectory The default directory to scan for model files + * (e.g., "/data/local/tmp/whisper" or "/data/local/tmp/parakeet") + * @param supportsPreprocessor Whether the app supports preprocessor file selection */ - fun initialize() { + fun initialize(defaultDirectory: String, supportsPreprocessor: Boolean = false) { + this.defaultDirectory = defaultDirectory + this.supportsPreprocessor = supportsPreprocessor refreshFileLists() } /** - * Set the app-internal storage directory (filesDir/whisper) so we can scan it too. + * Set the app-internal storage directory so we can scan it too. + * + * @param dir The full path to the app storage directory + * (e.g., "${filesDir}/whisper" or "${filesDir}/parakeet") */ - fun setAppStorageDirectory(filesDir: String) { - appStorageDirectory = "$filesDir/whisper" + fun setAppStorageDirectory(dir: String) { + appStorageDirectory = dir refreshFileLists() } @@ -55,13 +68,15 @@ class ModelSettingsViewModel : ViewModel() { */ fun refreshFileLists() { val directories = buildList { - add(ModelSettings.DEFAULT_DIRECTORY) + if (defaultDirectory.isNotEmpty()) add(defaultDirectory) appStorageDirectory?.let { add(it) } } availableModels = listLocalFilesFromDirs(directories, ModelSettings.MODEL_EXTENSIONS) availableTokenizers = listLocalFilesFromDirs(directories, ModelSettings.TOKENIZER_EXTENSIONS) - availablePreprocessors = listLocalFilesFromDirs(directories, ModelSettings.MODEL_EXTENSIONS) + if (supportsPreprocessor) { + availablePreprocessors = listLocalFilesFromDirs(directories, ModelSettings.MODEL_EXTENSIONS) + } availableDataFiles = listLocalFilesFromDirs(directories, ModelSettings.DATA_EXTENSIONS) availableWavFiles = listLocalFilesFromDirs(directories, WAV_EXTENSIONS) diff --git a/whisper/android/WhisperApp/app/src/main/java/com/example/whisperapp/ui/theme/Theme.kt b/asr/android/shared/lib/src/main/java/com/example/asr/ui/theme/AsrTheme.kt similarity index 93% rename from whisper/android/WhisperApp/app/src/main/java/com/example/whisperapp/ui/theme/Theme.kt rename to asr/android/shared/lib/src/main/java/com/example/asr/ui/theme/AsrTheme.kt index 39b80616..a98f545f 100644 --- a/whisper/android/WhisperApp/app/src/main/java/com/example/whisperapp/ui/theme/Theme.kt +++ b/asr/android/shared/lib/src/main/java/com/example/asr/ui/theme/AsrTheme.kt @@ -1,4 +1,4 @@ -package com.example.whisperapp.ui.theme +package com.example.asr.ui.theme import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme @@ -9,7 +9,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext @Composable -fun WhisperAppTheme( +fun AsrTheme( darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit ) { diff --git a/whisper/android/WhisperApp/app/src/test/java/com/example/whisperapp/ModelSettingsTest.kt b/asr/android/shared/lib/src/test/java/com/example/asr/ModelSettingsTest.kt similarity index 71% rename from whisper/android/WhisperApp/app/src/test/java/com/example/whisperapp/ModelSettingsTest.kt rename to asr/android/shared/lib/src/test/java/com/example/asr/ModelSettingsTest.kt index 4060459d..d51f6508 100644 --- a/whisper/android/WhisperApp/app/src/test/java/com/example/whisperapp/ModelSettingsTest.kt +++ b/asr/android/shared/lib/src/test/java/com/example/asr/ModelSettingsTest.kt @@ -1,4 +1,4 @@ -package com.example.whisperapp +package com.example.asr import org.junit.Assert.* import org.junit.Test @@ -12,7 +12,7 @@ class ModelSettingsTest { fun `isValid returns false when model path is empty`() { val settings = ModelSettings( modelPath = "", - tokenizerPath = "/data/local/tmp/whisper/tokenizer.json" + tokenizerPath = "/data/local/tmp/asr/tokenizer.json" ) assertFalse(settings.isValid()) } @@ -20,7 +20,7 @@ class ModelSettingsTest { @Test fun `isValid returns false when tokenizer path is empty`() { val settings = ModelSettings( - modelPath = "/data/local/tmp/whisper/model.pte", + modelPath = "/data/local/tmp/asr/model.pte", tokenizerPath = "" ) assertFalse(settings.isValid()) @@ -35,8 +35,8 @@ class ModelSettingsTest { @Test fun `isValid returns true when model and tokenizer are set`() { val settings = ModelSettings( - modelPath = "/data/local/tmp/whisper/model.pte", - tokenizerPath = "/data/local/tmp/whisper/tokenizer.json" + modelPath = "/data/local/tmp/asr/model.pte", + tokenizerPath = "/data/local/tmp/asr/tokenizer.json" ) assertTrue(settings.isValid()) } @@ -44,18 +44,28 @@ class ModelSettingsTest { @Test fun `isValid returns true even without preprocessor`() { val settings = ModelSettings( - modelPath = "/data/local/tmp/whisper/model.pte", - tokenizerPath = "/data/local/tmp/whisper/tokenizer.json", + modelPath = "/data/local/tmp/asr/model.pte", + tokenizerPath = "/data/local/tmp/asr/tokenizer.json", preprocessorPath = "" ) assertTrue(settings.isValid()) } + @Test + fun `isValid returns true with data path`() { + val settings = ModelSettings( + modelPath = "/data/local/tmp/asr/model.pte", + tokenizerPath = "/data/local/tmp/asr/tokenizer.model", + dataPath = "/data/local/tmp/asr/data.ptd" + ) + assertTrue(settings.isValid()) + } + @Test fun `hasPreprocessor returns false when preprocessor is empty`() { val settings = ModelSettings( - modelPath = "/data/local/tmp/whisper/model.pte", - tokenizerPath = "/data/local/tmp/whisper/tokenizer.json", + modelPath = "/data/local/tmp/asr/model.pte", + tokenizerPath = "/data/local/tmp/asr/tokenizer.json", preprocessorPath = "" ) assertFalse(settings.hasPreprocessor()) @@ -64,8 +74,8 @@ class ModelSettingsTest { @Test fun `hasPreprocessor returns false when preprocessor is blank`() { val settings = ModelSettings( - modelPath = "/data/local/tmp/whisper/model.pte", - tokenizerPath = "/data/local/tmp/whisper/tokenizer.json", + modelPath = "/data/local/tmp/asr/model.pte", + tokenizerPath = "/data/local/tmp/asr/tokenizer.json", preprocessorPath = " " ) assertFalse(settings.hasPreprocessor()) @@ -74,18 +84,13 @@ class ModelSettingsTest { @Test fun `hasPreprocessor returns true when preprocessor is set`() { val settings = ModelSettings( - modelPath = "/data/local/tmp/whisper/model.pte", - tokenizerPath = "/data/local/tmp/whisper/tokenizer.json", - preprocessorPath = "/data/local/tmp/whisper/preprocess.pte" + modelPath = "/data/local/tmp/asr/model.pte", + tokenizerPath = "/data/local/tmp/asr/tokenizer.json", + preprocessorPath = "/data/local/tmp/asr/preprocess.pte" ) assertTrue(settings.hasPreprocessor()) } - @Test - fun `default directory constant is correct`() { - assertEquals("/data/local/tmp/whisper", ModelSettings.DEFAULT_DIRECTORY) - } - @Test fun `model extensions include pte`() { assertTrue(ModelSettings.MODEL_EXTENSIONS.contains(".pte")) diff --git a/whisper/android/WhisperApp/app/src/test/java/com/example/whisperapp/ModelSettingsViewModelTest.kt b/asr/android/shared/lib/src/test/java/com/example/asr/ModelSettingsViewModelTest.kt similarity index 98% rename from whisper/android/WhisperApp/app/src/test/java/com/example/whisperapp/ModelSettingsViewModelTest.kt rename to asr/android/shared/lib/src/test/java/com/example/asr/ModelSettingsViewModelTest.kt index f1ccb276..6c122cbb 100644 --- a/whisper/android/WhisperApp/app/src/test/java/com/example/whisperapp/ModelSettingsViewModelTest.kt +++ b/asr/android/shared/lib/src/test/java/com/example/asr/ModelSettingsViewModelTest.kt @@ -1,8 +1,7 @@ -package com.example.whisperapp +package com.example.asr import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue -import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder diff --git a/asr/android/shared/settings.gradle.kts b/asr/android/shared/settings.gradle.kts new file mode 100644 index 00000000..32872311 --- /dev/null +++ b/asr/android/shared/settings.gradle.kts @@ -0,0 +1,23 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "asr-shared" +include(":lib") diff --git a/parakeet/android/ParakeetApp/app/build.gradle.kts b/parakeet/android/ParakeetApp/app/build.gradle.kts index 4a5b06de..0ff3d033 100644 --- a/parakeet/android/ParakeetApp/app/build.gradle.kts +++ b/parakeet/android/ParakeetApp/app/build.gradle.kts @@ -48,6 +48,7 @@ dependencies { implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) + implementation("com.example.asr:lib") testImplementation(libs.junit) debugImplementation(libs.androidx.ui.tooling) if (useLocalAar == true) { diff --git a/parakeet/android/ParakeetApp/app/src/main/java/com/example/parakeetapp/MainActivity.kt b/parakeet/android/ParakeetApp/app/src/main/java/com/example/parakeetapp/MainActivity.kt index 183a9f90..1efb16c7 100644 --- a/parakeet/android/ParakeetApp/app/src/main/java/com/example/parakeetapp/MainActivity.kt +++ b/parakeet/android/ParakeetApp/app/src/main/java/com/example/parakeetapp/MainActivity.kt @@ -45,7 +45,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModelProvider -import com.example.parakeetapp.ui.theme.ParakeetAppTheme +import com.example.asr.ModelSettings +import com.example.asr.ModelSettingsScreen +import com.example.asr.ModelSettingsViewModel +import com.example.asr.ui.theme.AsrTheme import org.pytorch.executorch.extension.parakeet.ParakeetModule import java.io.File import java.io.FileOutputStream @@ -100,8 +103,8 @@ class MainActivity : ComponentActivity() { // Initialize view models viewModel = ViewModelProvider(this)[ModelSettingsViewModel::class.java] - viewModel.setAppStorageDirectory(filesDir.absolutePath) - viewModel.initialize() + viewModel.setAppStorageDirectory("${filesDir.absolutePath}/parakeet") + viewModel.initialize("/data/local/tmp/parakeet") downloadViewModel = ViewModelProvider(this)[ModelDownloadViewModel::class.java] downloadViewModel.initialize(filesDir.absolutePath) @@ -120,7 +123,7 @@ class MainActivity : ComponentActivity() { } setContent { - ParakeetAppTheme { + AsrTheme { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background @@ -146,6 +149,7 @@ class MainActivity : ComponentActivity() { statusText = statusText, transcriptionResult = transcriptionOutput, modelSettings = viewModel.modelSettings, + defaultDirectory = viewModel.defaultDirectory, availableWavFiles = viewModel.availableWavFiles, showWavFileDialog = showWavFileDialog, onRecordStart = { startRecording() }, @@ -488,6 +492,7 @@ fun ParakeetScreen( statusText: String, transcriptionResult: String, modelSettings: ModelSettings, + defaultDirectory: String, availableWavFiles: List, showWavFileDialog: Boolean, onRecordStart: () -> Unit, @@ -659,6 +664,7 @@ fun ParakeetScreen( if (showWavFileDialog) { WavFileSelectionDialog( files = availableWavFiles, + defaultDirectory = defaultDirectory, onDismiss = onWavDialogDismiss, onSelect = onWavFileSelected ) @@ -668,6 +674,7 @@ fun ParakeetScreen( @Composable fun WavFileSelectionDialog( files: List, + defaultDirectory: String, onDismiss: () -> Unit, onSelect: (String) -> Unit ) { @@ -679,10 +686,10 @@ fun WavFileSelectionDialog( text = { if (files.isEmpty()) { Column { - Text("No WAV files found in ${ModelSettings.DEFAULT_DIRECTORY}") + Text("No WAV files found in $defaultDirectory") Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Use adb to push WAV files:\nadb push audio.wav ${ModelSettings.DEFAULT_DIRECTORY}/", + text = "Use adb to push WAV files:\nadb push audio.wav $defaultDirectory/", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/parakeet/android/ParakeetApp/app/src/main/java/com/example/parakeetapp/ModelSettings.kt b/parakeet/android/ParakeetApp/app/src/main/java/com/example/parakeetapp/ModelSettings.kt deleted file mode 100644 index f45b427e..00000000 --- a/parakeet/android/ParakeetApp/app/src/main/java/com/example/parakeetapp/ModelSettings.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.example.parakeetapp - -/** - * Data class representing the model file settings for Parakeet inference. - * - * @param modelPath Path to the main Parakeet model (.pte file) - * @param tokenizerPath Path to the tokenizer file (.json, .bin, or .model file) - * @param dataPath Optional path to external data file (.ptd file) - */ -data class ModelSettings( - val modelPath: String = "", - val tokenizerPath: String = "", - val dataPath: String = "" -) { - /** - * Check if the minimum required files are set for inference. - * Model and tokenizer are required. - */ - fun isValid(): Boolean { - return modelPath.isNotBlank() && tokenizerPath.isNotBlank() - } - - companion object { - const val DEFAULT_DIRECTORY = "/data/local/tmp/parakeet" - val MODEL_EXTENSIONS = arrayOf(".pte") - val TOKENIZER_EXTENSIONS = arrayOf(".json", ".bin", ".model") - val DATA_EXTENSIONS = arrayOf(".ptd") - } -} diff --git a/parakeet/android/ParakeetApp/app/src/main/java/com/example/parakeetapp/ModelSettingsScreen.kt b/parakeet/android/ParakeetApp/app/src/main/java/com/example/parakeetapp/ModelSettingsScreen.kt deleted file mode 100644 index 6f32a601..00000000 --- a/parakeet/android/ParakeetApp/app/src/main/java/com/example/parakeetapp/ModelSettingsScreen.kt +++ /dev/null @@ -1,306 +0,0 @@ -package com.example.parakeetapp - -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.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp - -/** - * Settings screen for selecting model files. - */ -@Composable -fun ModelSettingsScreen( - viewModel: ModelSettingsViewModel, - onBackClick: () -> Unit, - onDownloadClick: () -> Unit -) { - var showModelDialog by remember { mutableStateOf(false) } - var showTokenizerDialog by remember { mutableStateOf(false) } - var showDataDialog by remember { mutableStateOf(false) } - - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "Model Settings", - style = MaterialTheme.typography.headlineMedium, - modifier = Modifier.padding(bottom = 24.dp) - ) - - // Model file selection - FileSelectionRow( - label = "Model File (.pte)", - selectedPath = viewModel.modelSettings.modelPath, - required = true, - onClick = { - viewModel.refreshFileLists() - showModelDialog = true - } - ) - - Spacer(modifier = Modifier.height(16.dp)) - - // Tokenizer file selection - FileSelectionRow( - label = "Tokenizer File", - selectedPath = viewModel.modelSettings.tokenizerPath, - required = true, - onClick = { - viewModel.refreshFileLists() - showTokenizerDialog = true - } - ) - - Spacer(modifier = Modifier.height(16.dp)) - - // Data file selection (optional) - FileSelectionRow( - label = "Data File (.ptd) - Optional", - selectedPath = viewModel.modelSettings.dataPath, - required = false, - onClick = { - viewModel.refreshFileLists() - showDataDialog = true - }, - onClear = if (viewModel.modelSettings.dataPath.isNotBlank()) { - { viewModel.clearDataFile() } - } else null - ) - - Spacer(modifier = Modifier.height(24.dp)) - - // Download model button - OutlinedButton( - onClick = onDownloadClick, - modifier = Modifier.fillMaxWidth() - ) { - Text("Download Model") - } - - Spacer(modifier = Modifier.height(16.dp)) - - // Status indicator - if (viewModel.isReadyForInference()) { - Text( - text = "✓ Ready for inference", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary - ) - } else { - Text( - text = "⚠ Model and Tokenizer are required", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error - ) - } - - Spacer(modifier = Modifier.height(24.dp)) - - // Back button - Button( - onClick = onBackClick, - modifier = Modifier.fillMaxWidth() - ) { - Text("OK") - } - - // Info text - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = "Scanning: ${ModelSettings.DEFAULT_DIRECTORY} and app storage", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - // File selection dialogs - if (showModelDialog) { - FileSelectionDialog( - title = "Select Model File", - files = viewModel.availableModels, - currentSelection = viewModel.modelSettings.modelPath, - onDismiss = { showModelDialog = false }, - onSelect = { - viewModel.selectModel(it) - showModelDialog = false - } - ) - } - - if (showTokenizerDialog) { - FileSelectionDialog( - title = "Select Tokenizer File", - files = viewModel.availableTokenizers, - currentSelection = viewModel.modelSettings.tokenizerPath, - onDismiss = { showTokenizerDialog = false }, - onSelect = { - viewModel.selectTokenizer(it) - showTokenizerDialog = false - } - ) - } - - if (showDataDialog) { - FileSelectionDialog( - title = "Select Data File (Optional)", - files = viewModel.availableDataFiles, - currentSelection = viewModel.modelSettings.dataPath, - onDismiss = { showDataDialog = false }, - onSelect = { - viewModel.selectDataFile(it) - showDataDialog = false - }, - allowNone = true - ) - } -} - -/** - * Row displaying file selection information. - */ -@Composable -fun FileSelectionRow( - label: String, - selectedPath: String, - required: Boolean, - onClick: () -> Unit, - onClear: (() -> Unit)? = null -) { - Column(modifier = Modifier.fillMaxWidth()) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = label, - style = MaterialTheme.typography.titleSmall, - modifier = Modifier.weight(1f) - ) - - Row { - if (onClear != null) { - TextButton(onClick = onClear) { - Text("Clear") - } - } - Button(onClick = onClick) { - Text("Select") - } - } - } - - Spacer(modifier = Modifier.height(4.dp)) - - if (selectedPath.isNotBlank()) { - Text( - text = selectedPath.substringAfterLast('/'), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary - ) - Text( - text = selectedPath, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } else { - Text( - text = if (required) "Not selected (Required)" else "Not selected (Optional)", - style = MaterialTheme.typography.bodySmall, - color = if (required) MaterialTheme.colorScheme.error - else MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } -} - -/** - * Dialog for selecting a file from a list. - */ -@Composable -fun FileSelectionDialog( - title: String, - files: List, - currentSelection: String, - onDismiss: () -> Unit, - onSelect: (String) -> Unit, - allowNone: Boolean = false -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(title) }, - text = { - if (files.isEmpty()) { - Column { - Text("No files found. Download from the setup screen or use adb push to ${ModelSettings.DEFAULT_DIRECTORY}") - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Use adb to push files:\nadb push ${ModelSettings.DEFAULT_DIRECTORY}/", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } else { - Column(modifier = Modifier.verticalScroll(rememberScrollState())) { - if (allowNone) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onSelect("") }, - verticalAlignment = Alignment.CenterVertically - ) { - RadioButton( - selected = currentSelection.isBlank(), - onClick = { onSelect("") } - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("None", style = MaterialTheme.typography.bodyMedium) - } - Spacer(modifier = Modifier.height(8.dp)) - } - - files.forEach { filePath -> - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onSelect(filePath) }, - verticalAlignment = Alignment.CenterVertically - ) { - RadioButton( - selected = filePath == currentSelection, - onClick = { onSelect(filePath) } - ) - Spacer(modifier = Modifier.width(8.dp)) - Column { - Text( - text = filePath.substringAfterLast('/'), - style = MaterialTheme.typography.bodyMedium - ) - Text( - text = filePath, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - Spacer(modifier = Modifier.height(4.dp)) - } - } - } - }, - confirmButton = { - TextButton(onClick = onDismiss) { - Text("Close") - } - } - ) -} diff --git a/parakeet/android/ParakeetApp/app/src/main/java/com/example/parakeetapp/ModelSettingsViewModel.kt b/parakeet/android/ParakeetApp/app/src/main/java/com/example/parakeetapp/ModelSettingsViewModel.kt deleted file mode 100644 index f46731d2..00000000 --- a/parakeet/android/ParakeetApp/app/src/main/java/com/example/parakeetapp/ModelSettingsViewModel.kt +++ /dev/null @@ -1,118 +0,0 @@ -package com.example.parakeetapp - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.lifecycle.ViewModel -import java.io.File - -/** - * ViewModel for managing model file selection and settings. - * Settings are kept in memory only (not persisted). - */ -class ModelSettingsViewModel : ViewModel() { - - var modelSettings by mutableStateOf(ModelSettings()) - private set - - var availableModels by mutableStateOf>(emptyList()) - private set - - var availableTokenizers by mutableStateOf>(emptyList()) - private set - - var availableDataFiles by mutableStateOf>(emptyList()) - private set - - var availableWavFiles by mutableStateOf>(emptyList()) - private set - - var errorMessage by mutableStateOf(null) - private set - - private var appStorageDirectory: String? = null - - /** - * Initialize the ViewModel by scanning for available files. - */ - fun initialize() { - refreshFileLists() - } - - /** - * Set the app-internal storage directory (filesDir/parakeet) so we can scan it too. - */ - fun setAppStorageDirectory(filesDir: String) { - appStorageDirectory = "$filesDir/parakeet" - refreshFileLists() - } - - /** - * Scan all known directories for available model files. - */ - fun refreshFileLists() { - val directories = buildList { - add(ModelSettings.DEFAULT_DIRECTORY) - appStorageDirectory?.let { add(it) } - } - - availableModels = listLocalFilesFromDirs(directories, ModelSettings.MODEL_EXTENSIONS) - availableTokenizers = listLocalFilesFromDirs(directories, ModelSettings.TOKENIZER_EXTENSIONS) - availableDataFiles = listLocalFilesFromDirs(directories, ModelSettings.DATA_EXTENSIONS) - availableWavFiles = listLocalFilesFromDirs(directories, WAV_EXTENSIONS) - - if (availableModels.isNotEmpty() || availableTokenizers.isNotEmpty()) { - errorMessage = null - } - } - - fun selectModel(path: String) { - modelSettings = modelSettings.copy(modelPath = path) - } - - fun selectTokenizer(path: String) { - modelSettings = modelSettings.copy(tokenizerPath = path) - } - - fun selectDataFile(path: String) { - modelSettings = modelSettings.copy(dataPath = path) - } - - fun clearDataFile() { - selectDataFile("") - } - - fun isReadyForInference(): Boolean { - return modelSettings.isValid() - } - - companion object { - val WAV_EXTENSIONS = arrayOf(".wav") - - /** - * List files in the given directory matching the specified extensions. - */ - fun listLocalFiles(path: String, extensions: Array): List { - val directory = File(path) - if (!directory.exists() || !directory.isDirectory) { - return emptyList() - } - - return directory.listFiles { _, name -> - extensions.any { ext -> name.endsWith(ext, ignoreCase = true) } - }?.filter { it.isFile } - ?.map { it.absolutePath } - ?.sorted() - ?: emptyList() - } - - /** - * List files from multiple directories, deduplicated by absolute path. - */ - fun listLocalFilesFromDirs(dirs: List, extensions: Array): List { - return dirs.flatMap { listLocalFiles(it, extensions) } - .distinct() - .sorted() - } - } -} diff --git a/parakeet/android/ParakeetApp/app/src/main/java/com/example/parakeetapp/ui/theme/Theme.kt b/parakeet/android/ParakeetApp/app/src/main/java/com/example/parakeetapp/ui/theme/Theme.kt deleted file mode 100644 index 41a0e03d..00000000 --- a/parakeet/android/ParakeetApp/app/src/main/java/com/example/parakeetapp/ui/theme/Theme.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.example.parakeetapp.ui.theme - -import android.os.Build -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext - -@Composable -fun ParakeetAppTheme( - darkTheme: Boolean = isSystemInDarkTheme(), - content: @Composable () -> Unit -) { - val colorScheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } else { - if (darkTheme) { - androidx.compose.material3.darkColorScheme() - } else { - androidx.compose.material3.lightColorScheme() - } - } - - MaterialTheme( - colorScheme = colorScheme, - content = content - ) -} diff --git a/parakeet/android/ParakeetApp/app/src/test/java/com/example/parakeetapp/ModelSettingsTest.kt b/parakeet/android/ParakeetApp/app/src/test/java/com/example/parakeetapp/ModelSettingsTest.kt deleted file mode 100644 index 8dd0f003..00000000 --- a/parakeet/android/ParakeetApp/app/src/test/java/com/example/parakeetapp/ModelSettingsTest.kt +++ /dev/null @@ -1,89 +0,0 @@ -package com.example.parakeetapp - -import org.junit.Assert.* -import org.junit.Test - -/** - * Unit tests for ModelSettings data class. - */ -class ModelSettingsTest { - - @Test - fun `isValid returns false when model path is empty`() { - val settings = ModelSettings( - modelPath = "", - tokenizerPath = "/data/local/tmp/parakeet/tokenizer.model" - ) - assertFalse(settings.isValid()) - } - - @Test - fun `isValid returns false when tokenizer path is empty`() { - val settings = ModelSettings( - modelPath = "/data/local/tmp/parakeet/model.pte", - tokenizerPath = "" - ) - assertFalse(settings.isValid()) - } - - @Test - fun `isValid returns false when both paths are empty`() { - val settings = ModelSettings() - assertFalse(settings.isValid()) - } - - @Test - fun `isValid returns true when model and tokenizer are set`() { - val settings = ModelSettings( - modelPath = "/data/local/tmp/parakeet/model.pte", - tokenizerPath = "/data/local/tmp/parakeet/tokenizer.model" - ) - assertTrue(settings.isValid()) - } - - @Test - fun `isValid returns true with data path`() { - val settings = ModelSettings( - modelPath = "/data/local/tmp/parakeet/model.pte", - tokenizerPath = "/data/local/tmp/parakeet/tokenizer.model", - dataPath = "/data/local/tmp/parakeet/data.ptd" - ) - assertTrue(settings.isValid()) - } - - @Test - fun `default directory constant is correct`() { - assertEquals("/data/local/tmp/parakeet", ModelSettings.DEFAULT_DIRECTORY) - } - - @Test - fun `model extensions include pte`() { - assertTrue(ModelSettings.MODEL_EXTENSIONS.contains(".pte")) - } - - @Test - fun `tokenizer extensions include json bin and model`() { - assertTrue(ModelSettings.TOKENIZER_EXTENSIONS.contains(".json")) - assertTrue(ModelSettings.TOKENIZER_EXTENSIONS.contains(".bin")) - assertTrue(ModelSettings.TOKENIZER_EXTENSIONS.contains(".model")) - } - - @Test - fun `data extensions include ptd`() { - assertTrue(ModelSettings.DATA_EXTENSIONS.contains(".ptd")) - } - - @Test - fun `copy creates new instance with updated values`() { - val original = ModelSettings( - modelPath = "/path/to/model.pte", - tokenizerPath = "/path/to/tokenizer.model" - ) - val updated = original.copy(dataPath = "/path/to/data.ptd") - - assertEquals("/path/to/model.pte", updated.modelPath) - assertEquals("/path/to/tokenizer.model", updated.tokenizerPath) - assertEquals("/path/to/data.ptd", updated.dataPath) - assertEquals("", original.dataPath) // Original unchanged - } -} diff --git a/parakeet/android/ParakeetApp/app/src/test/java/com/example/parakeetapp/ModelSettingsViewModelTest.kt b/parakeet/android/ParakeetApp/app/src/test/java/com/example/parakeetapp/ModelSettingsViewModelTest.kt deleted file mode 100644 index 192b9ebd..00000000 --- a/parakeet/android/ParakeetApp/app/src/test/java/com/example/parakeetapp/ModelSettingsViewModelTest.kt +++ /dev/null @@ -1,134 +0,0 @@ -package com.example.parakeetapp - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.rules.TemporaryFolder -import java.io.File - -/** - * Unit tests for ModelSettingsViewModel. - */ -class ModelSettingsViewModelTest { - - @get:Rule - val tempFolder = TemporaryFolder() - - @Test - fun `listLocalFiles returns empty list for non-existent directory`() { - val files = ModelSettingsViewModel.listLocalFiles( - "/non/existent/path", - arrayOf(".pte") - ) - assertTrue(files.isEmpty()) - } - - @Test - fun `listLocalFiles returns empty list for empty directory`() { - val emptyDir = tempFolder.newFolder("empty") - val files = ModelSettingsViewModel.listLocalFiles( - emptyDir.absolutePath, - arrayOf(".pte") - ) - assertTrue(files.isEmpty()) - } - - @Test - fun `listLocalFiles filters by extension`() { - val dir = tempFolder.newFolder("models") - File(dir, "model1.pte").createNewFile() - File(dir, "model2.pte").createNewFile() - File(dir, "tokenizer.json").createNewFile() - File(dir, "readme.txt").createNewFile() - - val pteFiles = ModelSettingsViewModel.listLocalFiles( - dir.absolutePath, - arrayOf(".pte") - ) - - assertEquals(2, pteFiles.size) - assertTrue(pteFiles.all { it.endsWith(".pte") }) - } - - @Test - fun `listLocalFiles supports multiple extensions`() { - val dir = tempFolder.newFolder("tokens") - File(dir, "tokenizer.json").createNewFile() - File(dir, "tokenizer.bin").createNewFile() - File(dir, "tokenizer.model").createNewFile() - File(dir, "model.pte").createNewFile() - - val tokenizerFiles = ModelSettingsViewModel.listLocalFiles( - dir.absolutePath, - arrayOf(".json", ".bin", ".model") - ) - - assertEquals(3, tokenizerFiles.size) - assertTrue(tokenizerFiles.none { it.endsWith(".pte") }) - } - - @Test - fun `listLocalFiles returns absolute paths`() { - val dir = tempFolder.newFolder("abs") - File(dir, "model.pte").createNewFile() - - val files = ModelSettingsViewModel.listLocalFiles( - dir.absolutePath, - arrayOf(".pte") - ) - - assertEquals(1, files.size) - assertTrue(files[0].startsWith("/")) - assertTrue(files[0].contains(dir.name)) - } - - @Test - fun `listLocalFiles returns sorted list`() { - val dir = tempFolder.newFolder("sorted") - File(dir, "zebra.pte").createNewFile() - File(dir, "alpha.pte").createNewFile() - File(dir, "beta.pte").createNewFile() - - val files = ModelSettingsViewModel.listLocalFiles( - dir.absolutePath, - arrayOf(".pte") - ) - - assertEquals(3, files.size) - assertTrue(files[0].endsWith("alpha.pte")) - assertTrue(files[1].endsWith("beta.pte")) - assertTrue(files[2].endsWith("zebra.pte")) - } - - @Test - fun `listLocalFiles ignores directories`() { - val dir = tempFolder.newFolder("mixed") - File(dir, "model.pte").createNewFile() - File(dir, "subdir.pte").mkdirs() // Creates a directory with .pte extension - - val files = ModelSettingsViewModel.listLocalFiles( - dir.absolutePath, - arrayOf(".pte") - ) - - assertEquals(1, files.size) - assertTrue(files[0].endsWith("model.pte")) - } - - @Test - fun `listLocalFiles is case insensitive for extensions`() { - val dir = tempFolder.newFolder("case") - File(dir, "model.pte").createNewFile() - File(dir, "model2.PTE").createNewFile() - File(dir, "model3.Pte").createNewFile() - - val files = ModelSettingsViewModel.listLocalFiles( - dir.absolutePath, - arrayOf(".pte") - ) - - assertEquals(3, files.size) - } -} diff --git a/parakeet/android/ParakeetApp/settings.gradle.kts b/parakeet/android/ParakeetApp/settings.gradle.kts index da6aa006..0080fd0f 100644 --- a/parakeet/android/ParakeetApp/settings.gradle.kts +++ b/parakeet/android/ParakeetApp/settings.gradle.kts @@ -21,3 +21,4 @@ dependencyResolutionManagement { rootProject.name = "Parakeet App" include(":app") +includeBuild("../../../asr/android/shared") diff --git a/whisper/android/WhisperApp/app/build.gradle.kts b/whisper/android/WhisperApp/app/build.gradle.kts index c1f70356..c494bd76 100644 --- a/whisper/android/WhisperApp/app/build.gradle.kts +++ b/whisper/android/WhisperApp/app/build.gradle.kts @@ -48,6 +48,7 @@ dependencies { implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) + implementation("com.example.asr:lib") testImplementation(libs.junit) debugImplementation(libs.androidx.ui.tooling) if (useLocalAar == true) { diff --git a/whisper/android/WhisperApp/app/src/main/java/com/example/whisperapp/MainActivity.kt b/whisper/android/WhisperApp/app/src/main/java/com/example/whisperapp/MainActivity.kt index 265e0d8c..3364d051 100644 --- a/whisper/android/WhisperApp/app/src/main/java/com/example/whisperapp/MainActivity.kt +++ b/whisper/android/WhisperApp/app/src/main/java/com/example/whisperapp/MainActivity.kt @@ -43,7 +43,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModelProvider -import com.example.whisperapp.ui.theme.WhisperAppTheme +import com.example.asr.ModelSettings +import com.example.asr.ModelSettingsScreen +import com.example.asr.ModelSettingsViewModel +import com.example.asr.ui.theme.AsrTheme import org.pytorch.executorch.extension.asr.AsrCallback import org.pytorch.executorch.extension.asr.AsrModule import java.io.File @@ -106,8 +109,8 @@ class MainActivity : ComponentActivity(), AsrCallback { // Initialize view models viewModel = ViewModelProvider(this)[ModelSettingsViewModel::class.java] - viewModel.setAppStorageDirectory(filesDir.absolutePath) - viewModel.initialize() + viewModel.setAppStorageDirectory("${filesDir.absolutePath}/whisper") + viewModel.initialize("/data/local/tmp/whisper", supportsPreprocessor = true) downloadViewModel = ViewModelProvider(this)[ModelDownloadViewModel::class.java] downloadViewModel.initialize(filesDir.absolutePath) @@ -126,7 +129,7 @@ class MainActivity : ComponentActivity(), AsrCallback { } setContent { - WhisperAppTheme { + AsrTheme { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background @@ -152,6 +155,7 @@ class MainActivity : ComponentActivity(), AsrCallback { statusText = statusText, transcriptionResult = transcriptionOutput, modelSettings = viewModel.modelSettings, + defaultDirectory = viewModel.defaultDirectory, availableWavFiles = viewModel.availableWavFiles, showWavFileDialog = showWavFileDialog, onRecordClick = { onRecordButtonClick() }, @@ -174,7 +178,8 @@ class MainActivity : ComponentActivity(), AsrCallback { ModelSettingsScreen( viewModel = viewModel, onBackClick = { currentScreen = Screen.MAIN }, - onDownloadClick = { currentScreen = Screen.DOWNLOAD } + onDownloadClick = { currentScreen = Screen.DOWNLOAD }, + showPreprocessor = true ) } } @@ -527,6 +532,7 @@ fun WhisperScreen( statusText: String, transcriptionResult: String, modelSettings: ModelSettings, + defaultDirectory: String, availableWavFiles: List, showWavFileDialog: Boolean, onRecordClick: () -> Unit, @@ -680,6 +686,7 @@ fun WhisperScreen( if (showWavFileDialog) { WavFileSelectionDialog( files = availableWavFiles, + defaultDirectory = defaultDirectory, onDismiss = onWavDialogDismiss, onSelect = onWavFileSelected ) @@ -689,6 +696,7 @@ fun WhisperScreen( @Composable fun WavFileSelectionDialog( files: List, + defaultDirectory: String, onDismiss: () -> Unit, onSelect: (String) -> Unit ) { @@ -700,10 +708,10 @@ fun WavFileSelectionDialog( text = { if (files.isEmpty()) { Column { - Text("No WAV files found in ${ModelSettings.DEFAULT_DIRECTORY}") + Text("No WAV files found in $defaultDirectory") Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Use adb to push WAV files:\nadb push audio.wav ${ModelSettings.DEFAULT_DIRECTORY}/", + text = "Use adb to push WAV files:\nadb push audio.wav $defaultDirectory/", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/whisper/android/WhisperApp/settings.gradle.kts b/whisper/android/WhisperApp/settings.gradle.kts index 9b591175..dc9fa53e 100644 --- a/whisper/android/WhisperApp/settings.gradle.kts +++ b/whisper/android/WhisperApp/settings.gradle.kts @@ -21,4 +21,5 @@ dependencyResolutionManagement { rootProject.name = "Whisper App" include(":app") +includeBuild("../../../asr/android/shared") \ No newline at end of file From 5f69beb60dbc02fa0986b8b80e853d985985f339 Mon Sep 17 00:00:00 2001 From: Hansong Zhang Date: Fri, 20 Feb 2026 16:12:49 -0800 Subject: [PATCH 2/3] Merge Whisper and Parakeet into unified ASR app Combine both ASR apps into a single app at asr/android/AsrApp/ that supports both Whisper (streaming via AsrModule) and Parakeet (synchronous via ParakeetModule) models. The shared library module is folded directly into the app since there is now only one consumer. Key changes: - Add ModelType enum (WHISPER, PARAKEET) to ModelSettings - Unified download screen with all 7 presets (6 Whisper + 1 Parakeet) - Click-to-toggle recording for both; Whisper gets 30s auto-stop - Whisper token post-processing (strip leading/trailing tokens) - Model type selector in settings screen - Update CI to replace WhisperDemo/ParakeetDemo with AsrDemo - Delete whisper/, parakeet/, and asr/android/shared/ directories --- .github/workflows/android-build.yml | 11 +- .../android/AsrApp}/.gitignore | 0 .../android/AsrApp}/app/build.gradle.kts | 7 +- .../android/AsrApp}/app/proguard-rules.pro | 0 .../AsrApp}/app/src/main/AndroidManifest.xml | 0 .../java/com/example/asrapp}/MainActivity.kt | 179 +++-- .../example/asrapp}/ModelDownloadScreen.kt | 148 ++-- .../example/asrapp}/ModelDownloadViewModel.kt | 214 +++-- .../java/com/example/asrapp}/ModelSettings.kt | 14 +- .../example/asrapp}/ModelSettingsScreen.kt | 45 +- .../example/asrapp}/ModelSettingsViewModel.kt | 22 +- .../com/example/asrapp}/ui/theme/AsrTheme.kt | 2 +- .../res/mipmap-anydpi-v26/ic_launcher.xml | 0 .../mipmap-anydpi-v26/ic_launcher_round.xml | 0 .../src/main/res/mipmap-hdpi/ic_launcher.webp | Bin .../res/mipmap-hdpi/ic_launcher_round.webp | Bin .../src/main/res/mipmap-mdpi/ic_launcher.webp | Bin .../res/mipmap-mdpi/ic_launcher_round.webp | Bin .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin .../app/src/main/res/values/strings.xml | 3 + .../com/example/asrapp}/ModelSettingsTest.kt | 36 +- .../asrapp}/ModelSettingsViewModelTest.kt | 2 +- .../android/AsrApp}/build.gradle.kts | 0 .../android/AsrApp}/gradle.properties | 0 .../android/AsrApp}/gradle/libs.versions.toml | 1 - .../AsrApp}/gradle/wrapper/gradle-wrapper.jar | Bin .../gradle/wrapper/gradle-wrapper.properties | 0 .../android/AsrApp}/gradlew | 0 .../android/AsrApp}/gradlew.bat | 0 .../{shared => AsrApp}/settings.gradle.kts | 4 +- asr/android/shared/build.gradle.kts | 6 - asr/android/shared/gradle/libs.versions.toml | 33 - asr/android/shared/lib/build.gradle.kts | 35 - .../shared/lib/src/main/AndroidManifest.xml | 2 - parakeet/android/ParakeetApp/README.md | 37 - parakeet/android/ParakeetApp/app/.gitignore | 1 - .../android/ParakeetApp/app/build.gradle.kts | 60 -- .../com/example/parakeetapp/MainActivity.kt | 742 ------------------ .../parakeetapp/ModelDownloadScreen.kt | 261 ------ .../parakeetapp/ModelDownloadViewModel.kt | 212 ----- .../res/drawable/ic_launcher_background.xml | 170 ---- .../res/drawable/ic_launcher_foreground.xml | 30 - .../app/src/main/res/values/strings.xml | 3 - .../ParakeetApp/gradle/libs.versions.toml | 34 - .../gradle/wrapper/gradle-wrapper.properties | 6 - .../android/ParakeetApp/settings.gradle.kts | 24 - whisper/android/WhisperApp/.gitignore | 10 - whisper/android/WhisperApp/README.md | 22 - whisper/android/WhisperApp/app/.gitignore | 1 - .../android/WhisperApp/app/proguard-rules.pro | 21 - .../app/src/main/AndroidManifest.xml | 35 - .../res/drawable/ic_launcher_background.xml | 170 ---- .../res/drawable/ic_launcher_foreground.xml | 30 - .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 - .../mipmap-anydpi-v26/ic_launcher_round.xml | 6 - .../src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 1404 -> 0 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 2898 -> 0 bytes .../src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 982 -> 0 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 1772 -> 0 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 1900 -> 0 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 3918 -> 0 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 2884 -> 0 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 5914 -> 0 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 3844 -> 0 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 7778 -> 0 bytes .../app/src/main/res/values/strings.xml | 3 - whisper/android/WhisperApp/build.gradle.kts | 6 - whisper/android/WhisperApp/gradle.properties | 23 - .../gradle/wrapper/gradle-wrapper.jar | Bin 59203 -> 0 bytes .../gradle/wrapper/gradle-wrapper.properties | 6 - whisper/android/WhisperApp/gradlew | 185 ----- whisper/android/WhisperApp/gradlew.bat | 89 --- .../android/WhisperApp/settings.gradle.kts | 25 - 78 files changed, 485 insertions(+), 2497 deletions(-) rename {parakeet/android/ParakeetApp => asr/android/AsrApp}/.gitignore (100%) rename {whisper/android/WhisperApp => asr/android/AsrApp}/app/build.gradle.kts (92%) rename {parakeet/android/ParakeetApp => asr/android/AsrApp}/app/proguard-rules.pro (100%) rename {parakeet/android/ParakeetApp => asr/android/AsrApp}/app/src/main/AndroidManifest.xml (100%) rename {whisper/android/WhisperApp/app/src/main/java/com/example/whisperapp => asr/android/AsrApp/app/src/main/java/com/example/asrapp}/MainActivity.kt (83%) rename {whisper/android/WhisperApp/app/src/main/java/com/example/whisperapp => asr/android/AsrApp/app/src/main/java/com/example/asrapp}/ModelDownloadScreen.kt (65%) rename {whisper/android/WhisperApp/app/src/main/java/com/example/whisperapp => asr/android/AsrApp/app/src/main/java/com/example/asrapp}/ModelDownloadViewModel.kt (51%) rename asr/android/{shared/lib/src/main/java/com/example/asr => AsrApp/app/src/main/java/com/example/asrapp}/ModelSettings.kt (80%) rename asr/android/{shared/lib/src/main/java/com/example/asr => AsrApp/app/src/main/java/com/example/asrapp}/ModelSettingsScreen.kt (87%) rename asr/android/{shared/lib/src/main/java/com/example/asr => AsrApp/app/src/main/java/com/example/asrapp}/ModelSettingsViewModel.kt (85%) rename asr/android/{shared/lib/src/main/java/com/example/asr => AsrApp/app/src/main/java/com/example/asrapp}/ui/theme/AsrTheme.kt (96%) rename {parakeet/android/ParakeetApp => asr/android/AsrApp}/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml (100%) rename {parakeet/android/ParakeetApp => asr/android/AsrApp}/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml (100%) rename {parakeet/android/ParakeetApp => asr/android/AsrApp}/app/src/main/res/mipmap-hdpi/ic_launcher.webp (100%) rename {parakeet/android/ParakeetApp => asr/android/AsrApp}/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp (100%) rename {parakeet/android/ParakeetApp => asr/android/AsrApp}/app/src/main/res/mipmap-mdpi/ic_launcher.webp (100%) rename {parakeet/android/ParakeetApp => asr/android/AsrApp}/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp (100%) rename {parakeet/android/ParakeetApp => asr/android/AsrApp}/app/src/main/res/mipmap-xhdpi/ic_launcher.webp (100%) rename {parakeet/android/ParakeetApp => asr/android/AsrApp}/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp (100%) rename {parakeet/android/ParakeetApp => asr/android/AsrApp}/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp (100%) rename {parakeet/android/ParakeetApp => asr/android/AsrApp}/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp (100%) rename {parakeet/android/ParakeetApp => asr/android/AsrApp}/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp (100%) rename {parakeet/android/ParakeetApp => asr/android/AsrApp}/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp (100%) create mode 100644 asr/android/AsrApp/app/src/main/res/values/strings.xml rename asr/android/{shared/lib/src/test/java/com/example/asr => AsrApp/app/src/test/java/com/example/asrapp}/ModelSettingsTest.kt (77%) rename asr/android/{shared/lib/src/test/java/com/example/asr => AsrApp/app/src/test/java/com/example/asrapp}/ModelSettingsViewModelTest.kt (99%) rename {parakeet/android/ParakeetApp => asr/android/AsrApp}/build.gradle.kts (100%) rename {parakeet/android/ParakeetApp => asr/android/AsrApp}/gradle.properties (100%) rename {whisper/android/WhisperApp => asr/android/AsrApp}/gradle/libs.versions.toml (99%) rename {parakeet/android/ParakeetApp => asr/android/AsrApp}/gradle/wrapper/gradle-wrapper.jar (100%) rename asr/android/{shared => AsrApp}/gradle/wrapper/gradle-wrapper.properties (100%) rename {parakeet/android/ParakeetApp => asr/android/AsrApp}/gradlew (100%) rename {parakeet/android/ParakeetApp => asr/android/AsrApp}/gradlew.bat (100%) rename asr/android/{shared => AsrApp}/settings.gradle.kts (91%) delete mode 100644 asr/android/shared/build.gradle.kts delete mode 100644 asr/android/shared/gradle/libs.versions.toml delete mode 100644 asr/android/shared/lib/build.gradle.kts delete mode 100644 asr/android/shared/lib/src/main/AndroidManifest.xml delete mode 100644 parakeet/android/ParakeetApp/README.md delete mode 100644 parakeet/android/ParakeetApp/app/.gitignore delete mode 100644 parakeet/android/ParakeetApp/app/build.gradle.kts delete mode 100644 parakeet/android/ParakeetApp/app/src/main/java/com/example/parakeetapp/MainActivity.kt delete mode 100644 parakeet/android/ParakeetApp/app/src/main/java/com/example/parakeetapp/ModelDownloadScreen.kt delete mode 100644 parakeet/android/ParakeetApp/app/src/main/java/com/example/parakeetapp/ModelDownloadViewModel.kt delete mode 100644 parakeet/android/ParakeetApp/app/src/main/res/drawable/ic_launcher_background.xml delete mode 100644 parakeet/android/ParakeetApp/app/src/main/res/drawable/ic_launcher_foreground.xml delete mode 100644 parakeet/android/ParakeetApp/app/src/main/res/values/strings.xml delete mode 100644 parakeet/android/ParakeetApp/gradle/libs.versions.toml delete mode 100644 parakeet/android/ParakeetApp/gradle/wrapper/gradle-wrapper.properties delete mode 100644 parakeet/android/ParakeetApp/settings.gradle.kts delete mode 100644 whisper/android/WhisperApp/.gitignore delete mode 100644 whisper/android/WhisperApp/README.md delete mode 100644 whisper/android/WhisperApp/app/.gitignore delete mode 100644 whisper/android/WhisperApp/app/proguard-rules.pro delete mode 100644 whisper/android/WhisperApp/app/src/main/AndroidManifest.xml delete mode 100644 whisper/android/WhisperApp/app/src/main/res/drawable/ic_launcher_background.xml delete mode 100644 whisper/android/WhisperApp/app/src/main/res/drawable/ic_launcher_foreground.xml delete mode 100644 whisper/android/WhisperApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml delete mode 100644 whisper/android/WhisperApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml delete mode 100644 whisper/android/WhisperApp/app/src/main/res/mipmap-hdpi/ic_launcher.webp delete mode 100644 whisper/android/WhisperApp/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp delete mode 100644 whisper/android/WhisperApp/app/src/main/res/mipmap-mdpi/ic_launcher.webp delete mode 100644 whisper/android/WhisperApp/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp delete mode 100644 whisper/android/WhisperApp/app/src/main/res/mipmap-xhdpi/ic_launcher.webp delete mode 100644 whisper/android/WhisperApp/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp delete mode 100644 whisper/android/WhisperApp/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp delete mode 100644 whisper/android/WhisperApp/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp delete mode 100644 whisper/android/WhisperApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp delete mode 100644 whisper/android/WhisperApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp delete mode 100644 whisper/android/WhisperApp/app/src/main/res/values/strings.xml delete mode 100644 whisper/android/WhisperApp/build.gradle.kts delete mode 100644 whisper/android/WhisperApp/gradle.properties delete mode 100644 whisper/android/WhisperApp/gradle/wrapper/gradle-wrapper.jar delete mode 100644 whisper/android/WhisperApp/gradle/wrapper/gradle-wrapper.properties delete mode 100755 whisper/android/WhisperApp/gradlew delete mode 100644 whisper/android/WhisperApp/gradlew.bat delete mode 100644 whisper/android/WhisperApp/settings.gradle.kts diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml index cab135b4..80320115 100644 --- a/.github/workflows/android-build.yml +++ b/.github/workflows/android-build.yml @@ -36,11 +36,8 @@ jobs: path: mv3/android/MV3Demo - name: YoloDemo path: Yolo/android - - name: WhisperDemo - path: whisper/android/WhisperApp - require_aar: true - - name: ParakeetDemo - path: parakeet/android/ParakeetApp + - name: AsrDemo + path: asr/android/AsrApp require_aar: true @@ -62,7 +59,7 @@ jobs: uses: gradle/actions/setup-gradle@v4 - name: Download local AAR - if: ${{ inputs.local_aar && (matrix.name == 'LlamaDemo' || matrix.name == 'YoloDemo' || matrix.name == 'WhisperDemo' || matrix.name == 'ParakeetDemo') }} + if: ${{ inputs.local_aar && (matrix.name == 'LlamaDemo' || matrix.name == 'YoloDemo' || matrix.name == 'AsrDemo') }} run: | mkdir -p ${{ matrix.path }}/app/libs curl -fL -o ${{ matrix.path }}/app/libs/executorch.aar "${{ inputs.local_aar }}" @@ -71,7 +68,7 @@ jobs: if: ${{ !matrix.require_aar || inputs.local_aar }} working-directory: ${{ matrix.path }} run: | - if [ -n "${{ inputs.local_aar }}" ] && ([ "${{ matrix.name }}" == "LlamaDemo" ] || [ "${{ matrix.name }}" == "YoloDemo" ] || [ "${{ matrix.name }}" == "WhisperDemo" ] || [ "${{ matrix.name }}" == "ParakeetDemo" ]); then + if [ -n "${{ inputs.local_aar }}" ] && ([ "${{ matrix.name }}" == "LlamaDemo" ] || [ "${{ matrix.name }}" == "YoloDemo" ] || [ "${{ matrix.name }}" == "AsrDemo" ]); then ./gradlew build --no-daemon -PuseLocalAar=true else ./gradlew build --no-daemon diff --git a/parakeet/android/ParakeetApp/.gitignore b/asr/android/AsrApp/.gitignore similarity index 100% rename from parakeet/android/ParakeetApp/.gitignore rename to asr/android/AsrApp/.gitignore diff --git a/whisper/android/WhisperApp/app/build.gradle.kts b/asr/android/AsrApp/app/build.gradle.kts similarity index 92% rename from whisper/android/WhisperApp/app/build.gradle.kts rename to asr/android/AsrApp/app/build.gradle.kts index c494bd76..85075d4e 100644 --- a/whisper/android/WhisperApp/app/build.gradle.kts +++ b/asr/android/AsrApp/app/build.gradle.kts @@ -7,11 +7,11 @@ plugins { val useLocalAar: Boolean? = (project.findProperty("useLocalAar") as? String)?.toBoolean() android { - namespace = "com.example.whisperapp" + namespace = "com.example.asrapp" compileSdk = 35 defaultConfig { - applicationId = "com.example.whisperapp" + applicationId = "com.example.asrapp" minSdk = 24 targetSdk = 35 versionCode = 1 @@ -48,7 +48,6 @@ dependencies { implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) - implementation("com.example.asr:lib") testImplementation(libs.junit) debugImplementation(libs.androidx.ui.tooling) if (useLocalAar == true) { @@ -57,4 +56,4 @@ dependencies { implementation("org.pytorch:executorch-android:1.1.0") } implementation("com.facebook.fbjni:fbjni:0.5.1") -} \ No newline at end of file +} diff --git a/parakeet/android/ParakeetApp/app/proguard-rules.pro b/asr/android/AsrApp/app/proguard-rules.pro similarity index 100% rename from parakeet/android/ParakeetApp/app/proguard-rules.pro rename to asr/android/AsrApp/app/proguard-rules.pro diff --git a/parakeet/android/ParakeetApp/app/src/main/AndroidManifest.xml b/asr/android/AsrApp/app/src/main/AndroidManifest.xml similarity index 100% rename from parakeet/android/ParakeetApp/app/src/main/AndroidManifest.xml rename to asr/android/AsrApp/app/src/main/AndroidManifest.xml diff --git a/whisper/android/WhisperApp/app/src/main/java/com/example/whisperapp/MainActivity.kt b/asr/android/AsrApp/app/src/main/java/com/example/asrapp/MainActivity.kt similarity index 83% rename from whisper/android/WhisperApp/app/src/main/java/com/example/whisperapp/MainActivity.kt rename to asr/android/AsrApp/app/src/main/java/com/example/asrapp/MainActivity.kt index 3364d051..e3a136a2 100644 --- a/whisper/android/WhisperApp/app/src/main/java/com/example/whisperapp/MainActivity.kt +++ b/asr/android/AsrApp/app/src/main/java/com/example/asrapp/MainActivity.kt @@ -1,4 +1,4 @@ -package com.example.whisperapp +package com.example.asrapp import android.Manifest import android.content.pm.PackageManager @@ -43,12 +43,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModelProvider -import com.example.asr.ModelSettings -import com.example.asr.ModelSettingsScreen -import com.example.asr.ModelSettingsViewModel -import com.example.asr.ui.theme.AsrTheme +import com.example.asrapp.ui.theme.AsrTheme import org.pytorch.executorch.extension.asr.AsrCallback import org.pytorch.executorch.extension.asr.AsrModule +import org.pytorch.executorch.extension.parakeet.ParakeetModule import java.io.File import java.io.FileOutputStream import java.io.IOException @@ -59,7 +57,7 @@ class MainActivity : ComponentActivity(), AsrCallback { companion object { private const val TAG = "MainActivity" private const val RECORDING_DURATION_MS = 30000L // 30 seconds - // Token lengths to remove from transcription output + // Whisper token lengths to remove from transcription output private const val START_TOKEN_LENGTH = 37 private const val END_TOKEN_LENGTH = 13 } @@ -71,6 +69,7 @@ class MainActivity : ComponentActivity(), AsrCallback { private var statusText by mutableStateOf("") private var currentScreen by mutableStateOf(Screen.MAIN) private var showWavFileDialog by mutableStateOf(false) + private var currentModelType by mutableStateOf(ModelType.PARAKEET) private var isRecording = false private var audioRecord: AudioRecord? = null @@ -109,8 +108,8 @@ class MainActivity : ComponentActivity(), AsrCallback { // Initialize view models viewModel = ViewModelProvider(this)[ModelSettingsViewModel::class.java] - viewModel.setAppStorageDirectory("${filesDir.absolutePath}/whisper") - viewModel.initialize("/data/local/tmp/whisper", supportsPreprocessor = true) + viewModel.setAppStorageDirectory("${filesDir.absolutePath}/asr") + viewModel.initialize("/data/local/tmp/asr", modelType = ModelType.PARAKEET) downloadViewModel = ViewModelProvider(this)[ModelDownloadViewModel::class.java] downloadViewModel.initialize(filesDir.absolutePath) @@ -149,7 +148,7 @@ class MainActivity : ComponentActivity(), AsrCallback { ) } Screen.MAIN -> { - WhisperScreen( + AsrScreen( buttonText = buttonText, buttonEnabled = buttonEnabled && viewModel.isReadyForInference(), statusText = statusText, @@ -165,7 +164,7 @@ class MainActivity : ComponentActivity(), AsrCallback { }, onWavFileSelected = { wavPath -> showWavFileDialog = false - runWhisperFromFile(wavPath) + runFromFile(wavPath) }, onWavDialogDismiss = { showWavFileDialog = false }, onSettingsClick = { @@ -179,7 +178,8 @@ class MainActivity : ComponentActivity(), AsrCallback { viewModel = viewModel, onBackClick = { currentScreen = Screen.MAIN }, onDownloadClick = { currentScreen = Screen.DOWNLOAD }, - showPreprocessor = true + showModelTypeSelector = true, + onModelTypeChanged = { type -> currentModelType = type } ) } } @@ -189,9 +189,17 @@ class MainActivity : ComponentActivity(), AsrCallback { } private fun applyDownloadedModelPaths() { + val preset = downloadViewModel.getSelectedPreset() viewModel.selectModel(downloadViewModel.getModelPath()) viewModel.selectTokenizer(downloadViewModel.getTokenizerPath()) - viewModel.selectPreprocessor(downloadViewModel.getPreprocessorPath()) + val preprocessorPath = downloadViewModel.getPreprocessorPath() + if (preprocessorPath.isNotEmpty()) { + viewModel.selectPreprocessor(preprocessorPath) + } else { + viewModel.clearPreprocessor() + } + viewModel.selectModelType(preset.modelType) + currentModelType = preset.modelType } private fun onRecordButtonClick() { @@ -203,23 +211,18 @@ class MainActivity : ComponentActivity(), AsrCallback { } /** - * Run Whisper inference on the recorded audio file. + * Run inference on a WAV file from disk. */ - private fun runWhisper() { - val wavFile = File(getExternalFilesDir(null), "audio_record.wav") - runWhisperOnWavFile(wavFile.absolutePath) - } - - /** - * Run Whisper inference on a WAV file from /data/local/tmp/whisper. - */ - private fun runWhisperFromFile(wavFilePath: String) { + private fun runFromFile(wavFilePath: String) { buttonEnabled = false statusText = "Loading WAV file..." Thread { try { - runWhisperOnWavFile(wavFilePath) + when (currentModelType) { + ModelType.WHISPER -> runWhisperOnWavFile(wavFilePath) + ModelType.PARAKEET -> runParakeetOnWavFile(wavFilePath) + } } catch (e: Exception) { Log.e(TAG, "Error processing WAV file", e) runOnUiThread { @@ -231,8 +234,18 @@ class MainActivity : ComponentActivity(), AsrCallback { } /** - * Common method to run Whisper on a WAV file path. + * Run inference on the recorded audio file. */ + private fun runInference() { + val wavFile = File(getExternalFilesDir(null), "audio_record.wav") + when (currentModelType) { + ModelType.WHISPER -> runWhisperOnWavFile(wavFile.absolutePath) + ModelType.PARAKEET -> runParakeetOnWavFile(wavFile.absolutePath) + } + } + + // --- Whisper inference --- + private fun runWhisperOnWavFile(wavFilePath: String) { val settings = viewModel.modelSettings @@ -259,7 +272,7 @@ class MainActivity : ComponentActivity(), AsrCallback { preprocessorPath = settings.preprocessorPath.ifBlank { null } ) - Log.v(TAG, "Starting transcribe for: $wavFilePath") + Log.v(TAG, "Starting Whisper transcribe for: $wavFilePath") runOnUiThread { statusText = "Transcribing..." } @@ -267,10 +280,8 @@ class MainActivity : ComponentActivity(), AsrCallback { whisperModule.transcribe(wavFilePath, callback = this@MainActivity) val elapsedTime = System.currentTimeMillis() - startTime val elapsedSeconds = elapsedTime / 1000.0 - Log.v(TAG, "Finished transcribe in ${elapsedSeconds}s") + Log.v(TAG, "Finished Whisper transcribe in ${elapsedSeconds}s") - // Display result in Text view instead of Toast - // hack to remove start and end tokens; ideally the runner should not do callback on these tokens runOnUiThread { val minLength = START_TOKEN_LENGTH + END_TOKEN_LENGTH if (rawTranscriptionOutput.length > minLength) { @@ -289,7 +300,6 @@ class MainActivity : ComponentActivity(), AsrCallback { Log.v(TAG, "Called callback: here's the current output") rawTranscriptionOutput += result runOnUiThread { - // Strip start token prefix for display while transcribing if (rawTranscriptionOutput.length > START_TOKEN_LENGTH) { transcriptionOutput = rawTranscriptionOutput.substring(START_TOKEN_LENGTH) } @@ -297,13 +307,60 @@ class MainActivity : ComponentActivity(), AsrCallback { Log.v(TAG, rawTranscriptionOutput) } + // --- Parakeet inference --- + + private fun runParakeetOnWavFile(wavFilePath: String) { + val settings = viewModel.modelSettings + + if (!settings.isValid()) { + runOnUiThread { + statusText = "Please select model and tokenizer in Settings" + buttonEnabled = true + } + return + } + + runOnUiThread { + transcriptionOutput = "" + statusText = "Loading model..." + buttonText = "Transcribing..." + buttonEnabled = false + } + + val parakeetModule = ParakeetModule( + modelPath = settings.modelPath, + tokenizerPath = settings.tokenizerPath, + dataPath = settings.dataPath.ifBlank { null } + ) + + Log.v(TAG, "Starting Parakeet transcribe for: $wavFilePath") + runOnUiThread { + statusText = "Transcribing..." + } + val startTime = System.currentTimeMillis() + val result = parakeetModule.transcribe(wavFilePath) + val elapsedTime = System.currentTimeMillis() - startTime + val elapsedSeconds = elapsedTime / 1000.0 + Log.v(TAG, "Finished Parakeet transcribe in ${elapsedSeconds}s") + + parakeetModule.close() + + runOnUiThread { + transcriptionOutput = result + statusText = "Transcription complete (%.2fs)".format(elapsedSeconds) + buttonText = "Record" + buttonEnabled = true + } + } + + // --- Recording --- + private fun startRecording() { when { ContextCompat.checkSelfPermission( this, Manifest.permission.RECORD_AUDIO ) == PackageManager.PERMISSION_GRANTED -> { - // Permission already granted, start recording try { audioRecord = AudioRecord( MediaRecorder.AudioSource.MIC, @@ -322,14 +379,18 @@ class MainActivity : ComponentActivity(), AsrCallback { audioRecord?.startRecording() isRecording = true - buttonText = "Recording... (30s)" - buttonEnabled = false + if (currentModelType == ModelType.WHISPER) { + buttonText = "Recording... (30s)" + buttonEnabled = false - // Schedule automatic stop after 30 seconds - stopRecordingRunnable = Runnable { - stopRecording() + // Schedule automatic stop after 30 seconds for Whisper + stopRecordingRunnable = Runnable { + stopRecording() + } + handler.postDelayed(stopRecordingRunnable!!, RECORDING_DURATION_MS) + } else { + buttonText = "Recording... (tap to stop)" } - handler.postDelayed(stopRecordingRunnable!!, RECORDING_DURATION_MS) val pcmFile = File(getExternalFilesDir(null), "audio_record.pcm") @@ -350,7 +411,7 @@ class MainActivity : ComponentActivity(), AsrCallback { runOnUiThread { writeWavFile(pcmFile) statusText = "Recording saved" - runWhisper() + runInference() } } catch (e: IOException) { @@ -381,8 +442,14 @@ class MainActivity : ComponentActivity(), AsrCallback { } private fun stopRecording() { + if (!isRecording) return + isRecording = false + // Cancel any pending auto-stop + stopRecordingRunnable?.let { handler.removeCallbacks(it) } + stopRecordingRunnable = null + try { audioRecord?.stop() audioRecord?.release() @@ -391,8 +458,8 @@ class MainActivity : ComponentActivity(), AsrCallback { } audioRecord = null - buttonText = "Record" - buttonEnabled = true + buttonText = "Processing..." + buttonEnabled = false recordingThread?.join() recordingThread = null @@ -405,7 +472,6 @@ class MainActivity : ComponentActivity(), AsrCallback { val wavOut = FileOutputStream(wavFile) - // Write WAV header for 16-bit mono audio at 16 kHz writeWavHeader(wavOut, pcmData.size.toLong(), sampleRate, 1, 16) wavOut.write(pcmData) wavOut.flush() @@ -526,7 +592,7 @@ class MainActivity : ComponentActivity(), AsrCallback { } @Composable -fun WhisperScreen( +fun AsrScreen( buttonText: String, buttonEnabled: Boolean, statusText: String, @@ -552,7 +618,7 @@ fun WhisperScreen( Spacer(modifier = Modifier.height(32.dp)) Text( - text = "Whisper Demo", + text = "ASR Demo", style = MaterialTheme.typography.headlineMedium ) @@ -573,6 +639,11 @@ fun WhisperScreen( Spacer(modifier = Modifier.height(8.dp)) if (modelSettings.isValid()) { + Text( + text = "Type: ${modelSettings.modelType.name.lowercase() + .replaceFirstChar { it.uppercase() }}", + style = MaterialTheme.typography.bodySmall + ) Text( text = "Model: ${modelSettings.modelPath.substringAfterLast('/')}", style = MaterialTheme.typography.bodySmall @@ -581,17 +652,19 @@ fun WhisperScreen( text = "Tokenizer: ${modelSettings.tokenizerPath.substringAfterLast('/')}", style = MaterialTheme.typography.bodySmall ) - if (modelSettings.hasPreprocessor()) { - Text( - text = "Preprocessor: ${modelSettings.preprocessorPath.substringAfterLast('/')}", - style = MaterialTheme.typography.bodySmall - ) - } else { - Text( - text = "Preprocessor: None (raw WAV mode)", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + if (modelSettings.modelType == ModelType.WHISPER) { + if (modelSettings.hasPreprocessor()) { + Text( + text = "Preprocessor: ${modelSettings.preprocessorPath.substringAfterLast('/')}", + style = MaterialTheme.typography.bodySmall + ) + } else { + Text( + text = "Preprocessor: None (raw WAV mode)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } if (modelSettings.dataPath.isNotBlank()) { Text( diff --git a/whisper/android/WhisperApp/app/src/main/java/com/example/whisperapp/ModelDownloadScreen.kt b/asr/android/AsrApp/app/src/main/java/com/example/asrapp/ModelDownloadScreen.kt similarity index 65% rename from whisper/android/WhisperApp/app/src/main/java/com/example/whisperapp/ModelDownloadScreen.kt rename to asr/android/AsrApp/app/src/main/java/com/example/asrapp/ModelDownloadScreen.kt index b8dd51f2..526922f9 100644 --- a/whisper/android/WhisperApp/app/src/main/java/com/example/whisperapp/ModelDownloadScreen.kt +++ b/asr/android/AsrApp/app/src/main/java/com/example/asrapp/ModelDownloadScreen.kt @@ -1,4 +1,4 @@ -package com.example.whisperapp +package com.example.asrapp import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -53,7 +53,7 @@ fun ModelDownloadScreen( Spacer(modifier = Modifier.height(24.dp)) - // Preset selection + // Preset selection grouped by model type Surface( modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.surfaceVariant, @@ -66,43 +66,56 @@ fun ModelDownloadScreen( ) Spacer(modifier = Modifier.height(8.dp)) - ModelDownloadViewModel.MODEL_PRESETS.forEachIndexed { index, preset -> - val alreadyDownloaded = downloadViewModel.isPresetDownloaded(preset) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - RadioButton( - selected = index == selectedIndex, - onClick = { - if (!isDownloading) { - downloadViewModel.selectPreset(index) - downloadViewModel.resetStatus() - } - }, - enabled = !isDownloading + // Group presets by model type + val groupedPresets = ModelDownloadViewModel.MODEL_PRESETS + .withIndex() + .groupBy { it.value.modelType } + + // Whisper Models section + groupedPresets[ModelType.WHISPER]?.let { whisperPresets -> + Text( + text = "Whisper Models", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 4.dp, bottom = 4.dp) + ) + + whisperPresets.forEach { (index, preset) -> + PresetRow( + preset = preset, + isSelected = index == selectedIndex, + isDownloaded = downloadViewModel.isPresetDownloaded(preset), + isDownloading = isDownloading, + onSelect = { + downloadViewModel.selectPreset(index) + downloadViewModel.resetStatus() + } ) - Spacer(modifier = Modifier.width(4.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = preset.displayName, - style = MaterialTheme.typography.bodyMedium - ) - Text( - text = preset.modelFile.filename, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - if (alreadyDownloaded) { - Text( - text = "✓", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary - ) - } + Spacer(modifier = Modifier.height(4.dp)) } - if (index < ModelDownloadViewModel.MODEL_PRESETS.size - 1) { + } + + // Parakeet Models section + groupedPresets[ModelType.PARAKEET]?.let { parakeetPresets -> + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Parakeet Models", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 4.dp, bottom = 4.dp) + ) + + parakeetPresets.forEach { (index, preset) -> + PresetRow( + preset = preset, + isSelected = index == selectedIndex, + isDownloaded = downloadViewModel.isPresetDownloaded(preset), + isDownloading = isDownloading, + onSelect = { + downloadViewModel.selectPreset(index) + downloadViewModel.resetStatus() + } + ) Spacer(modifier = Modifier.height(4.dp)) } } @@ -124,15 +137,14 @@ fun ModelDownloadScreen( ) Spacer(modifier = Modifier.height(8.dp)) - // Shared files - ModelDownloadViewModel.SHARED_FILES.forEach { fileInfo -> - FileStatusRow(fileInfo.description, fileInfo.filename, downloadViewModel) - Spacer(modifier = Modifier.height(4.dp)) - } - - // Model-specific file val preset = downloadViewModel.getSelectedPreset() - FileStatusRow(preset.modelFile.description, preset.modelFile.filename, downloadViewModel) + + preset.files.forEachIndexed { index, file -> + FileStatusRow(file.description, file.filename, downloadViewModel) + if (index < preset.files.size - 1) { + Spacer(modifier = Modifier.height(4.dp)) + } + } // Download progress if (isDownloading) { @@ -227,6 +239,46 @@ fun ModelDownloadScreen( } } +@Composable +private fun PresetRow( + preset: AsrModelPreset, + isSelected: Boolean, + isDownloaded: Boolean, + isDownloading: Boolean, + onSelect: () -> Unit +) { + val modelFile = preset.files.first { it.type == FileType.MODEL } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = isSelected, + onClick = { if (!isDownloading) onSelect() }, + enabled = !isDownloading + ) + Spacer(modifier = Modifier.width(4.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = preset.displayName, + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = modelFile.filename, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (isDownloaded) { + Text( + text = "\u2713", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + } + } +} + @Composable private fun FileStatusRow( description: String, @@ -252,9 +304,9 @@ private fun FileStatusRow( } Text( text = when { - fileExists || status == DownloadStatus.COMPLETED -> "✓" - status == DownloadStatus.DOWNLOADING && currentFileName == filename -> "⬇" - else -> "○" + fileExists || status == DownloadStatus.COMPLETED -> "\u2713" + status == DownloadStatus.DOWNLOADING && currentFileName == filename -> "\u2B07" + else -> "\u25CB" }, style = MaterialTheme.typography.bodyMedium ) diff --git a/whisper/android/WhisperApp/app/src/main/java/com/example/whisperapp/ModelDownloadViewModel.kt b/asr/android/AsrApp/app/src/main/java/com/example/asrapp/ModelDownloadViewModel.kt similarity index 51% rename from whisper/android/WhisperApp/app/src/main/java/com/example/whisperapp/ModelDownloadViewModel.kt rename to asr/android/AsrApp/app/src/main/java/com/example/asrapp/ModelDownloadViewModel.kt index 37cd1ba2..e80a808b 100644 --- a/whisper/android/WhisperApp/app/src/main/java/com/example/whisperapp/ModelDownloadViewModel.kt +++ b/asr/android/AsrApp/app/src/main/java/com/example/asrapp/ModelDownloadViewModel.kt @@ -1,4 +1,4 @@ -package com.example.whisperapp +package com.example.asrapp import android.util.Log import androidx.compose.runtime.getValue @@ -16,16 +16,20 @@ import java.io.FileOutputStream import java.net.HttpURLConnection import java.net.URL -data class ModelFileInfo( +enum class FileType { MODEL, TOKENIZER, PREPROCESSOR } + +data class PresetFile( val url: String, val filename: String, - val description: String + val description: String, + val type: FileType ) -data class WhisperModelPreset( +data class AsrModelPreset( val id: String, val displayName: String, - val modelFile: ModelFileInfo + val modelType: ModelType, + val files: List ) enum class DownloadStatus { @@ -39,8 +43,9 @@ class ModelDownloadViewModel : ViewModel() { companion object { private const val TAG = "ModelDownloadViewModel" - private const val MODELS_SUBDIRECTORY = "whisper" + private const val MODELS_SUBDIRECTORY = "asr" + // Whisper URLs private const val TINY_INT8_URL = "https://huggingface.co/larryliu0820/whisper-tiny-INT8-INT4-ExecuTorch-XNNPACK/resolve/main" private const val SMALL_INT8_URL = @@ -54,72 +59,134 @@ class ModelDownloadViewModel : ViewModel() { private const val MEDIUM_FP32_URL = "https://huggingface.co/larryliu0820/whisper-medium-ExecuTorch-XNNPACK/resolve/main" - val SHARED_FILES = listOf( - ModelFileInfo( - url = "$TINY_INT8_URL/tokenizer.json", - filename = "tokenizer.json", - description = "Tokenizer" - ), - ModelFileInfo( - url = "$TINY_INT8_URL/whisper_preprocessor.pte", - filename = "whisper_preprocessor.pte", - description = "Preprocessor" - ) + // Parakeet URLs + private const val PARAKEET_BASE_URL = + "https://huggingface.co/larryliu0820/parakeet-tdt-0.6b-v3-executorch/resolve/main/xnnpack/int4" + + // Shared Whisper files (tokenizer + preprocessor) + private val WHISPER_TOKENIZER = PresetFile( + url = "$TINY_INT8_URL/tokenizer.json", + filename = "tokenizer.json", + description = "Tokenizer", + type = FileType.TOKENIZER + ) + private val WHISPER_PREPROCESSOR = PresetFile( + url = "$TINY_INT8_URL/whisper_preprocessor.pte", + filename = "whisper_preprocessor.pte", + description = "Preprocessor", + type = FileType.PREPROCESSOR ) val MODEL_PRESETS = listOf( - WhisperModelPreset( - id = "tiny_int8", + // Whisper presets + AsrModelPreset( + id = "whisper_tiny_int8", displayName = "Whisper Tiny (INT8/INT4)", - modelFile = ModelFileInfo( - url = "$TINY_INT8_URL/model.pte", - filename = "whisper_tiny_int8int4.pte", - description = "Whisper Tiny INT8/INT4 Model" + modelType = ModelType.WHISPER, + files = listOf( + PresetFile( + url = "$TINY_INT8_URL/model.pte", + filename = "whisper_tiny_int8int4.pte", + description = "Whisper Tiny INT8/INT4 Model", + type = FileType.MODEL + ), + WHISPER_TOKENIZER, + WHISPER_PREPROCESSOR ) ), - WhisperModelPreset( - id = "small_int8", + AsrModelPreset( + id = "whisper_small_int8", displayName = "Whisper Small (INT8/INT4)", - modelFile = ModelFileInfo( - url = "$SMALL_INT8_URL/model.pte", - filename = "whisper_small_int8int4.pte", - description = "Whisper Small INT8/INT4 Model" + modelType = ModelType.WHISPER, + files = listOf( + PresetFile( + url = "$SMALL_INT8_URL/model.pte", + filename = "whisper_small_int8int4.pte", + description = "Whisper Small INT8/INT4 Model", + type = FileType.MODEL + ), + WHISPER_TOKENIZER, + WHISPER_PREPROCESSOR ) ), - WhisperModelPreset( - id = "medium_int8", + AsrModelPreset( + id = "whisper_medium_int8", displayName = "Whisper Medium (INT8/INT4)", - modelFile = ModelFileInfo( - url = "$MEDIUM_INT8_URL/model.pte", - filename = "whisper_medium_int8int4.pte", - description = "Whisper Medium INT8/INT4 Model" + modelType = ModelType.WHISPER, + files = listOf( + PresetFile( + url = "$MEDIUM_INT8_URL/model.pte", + filename = "whisper_medium_int8int4.pte", + description = "Whisper Medium INT8/INT4 Model", + type = FileType.MODEL + ), + WHISPER_TOKENIZER, + WHISPER_PREPROCESSOR ) ), - WhisperModelPreset( - id = "tiny_fp32", + AsrModelPreset( + id = "whisper_tiny_fp32", displayName = "Whisper Tiny (FP32)", - modelFile = ModelFileInfo( - url = "$TINY_FP32_URL/model.pte", - filename = "whisper_tiny_fp32.pte", - description = "Whisper Tiny FP32 Model" + modelType = ModelType.WHISPER, + files = listOf( + PresetFile( + url = "$TINY_FP32_URL/model.pte", + filename = "whisper_tiny_fp32.pte", + description = "Whisper Tiny FP32 Model", + type = FileType.MODEL + ), + WHISPER_TOKENIZER, + WHISPER_PREPROCESSOR ) ), - WhisperModelPreset( - id = "small_fp32", + AsrModelPreset( + id = "whisper_small_fp32", displayName = "Whisper Small (FP32)", - modelFile = ModelFileInfo( - url = "$SMALL_FP32_URL/model.pte", - filename = "whisper_small_fp32.pte", - description = "Whisper Small FP32 Model" + modelType = ModelType.WHISPER, + files = listOf( + PresetFile( + url = "$SMALL_FP32_URL/model.pte", + filename = "whisper_small_fp32.pte", + description = "Whisper Small FP32 Model", + type = FileType.MODEL + ), + WHISPER_TOKENIZER, + WHISPER_PREPROCESSOR ) ), - WhisperModelPreset( - id = "medium_fp32", + AsrModelPreset( + id = "whisper_medium_fp32", displayName = "Whisper Medium (FP32)", - modelFile = ModelFileInfo( - url = "$MEDIUM_FP32_URL/model.pte", - filename = "whisper_medium_fp32.pte", - description = "Whisper Medium FP32 Model" + modelType = ModelType.WHISPER, + files = listOf( + PresetFile( + url = "$MEDIUM_FP32_URL/model.pte", + filename = "whisper_medium_fp32.pte", + description = "Whisper Medium FP32 Model", + type = FileType.MODEL + ), + WHISPER_TOKENIZER, + WHISPER_PREPROCESSOR + ) + ), + // Parakeet preset + AsrModelPreset( + id = "parakeet_int4", + displayName = "Parakeet TDT 0.6B (INT4)", + modelType = ModelType.PARAKEET, + files = listOf( + PresetFile( + url = "$PARAKEET_BASE_URL/model.pte", + filename = "parakeet_int4.pte", + description = "Parakeet TDT 0.6B INT4 Model", + type = FileType.MODEL + ), + PresetFile( + url = "$PARAKEET_BASE_URL/tokenizer.model", + filename = "parakeet_tokenizer.model", + description = "Tokenizer", + type = FileType.TOKENIZER + ) ) ) ) @@ -158,16 +225,25 @@ class ModelDownloadViewModel : ViewModel() { selectedPresetIndex = index } - fun getSelectedPreset(): WhisperModelPreset = MODEL_PRESETS[selectedPresetIndex] + fun getSelectedPreset(): AsrModelPreset = MODEL_PRESETS[selectedPresetIndex] + + fun getModelPath(): String { + val file = getSelectedPreset().files.first { it.type == FileType.MODEL } + return "$modelsDir/${file.filename}" + } + + fun getTokenizerPath(): String { + val file = getSelectedPreset().files.first { it.type == FileType.TOKENIZER } + return "$modelsDir/${file.filename}" + } - fun getModelPath(): String = "$modelsDir/${getSelectedPreset().modelFile.filename}" - fun getTokenizerPath(): String = "$modelsDir/${SHARED_FILES[0].filename}" - fun getPreprocessorPath(): String = "$modelsDir/${SHARED_FILES[1].filename}" + fun getPreprocessorPath(): String { + val file = getSelectedPreset().files.firstOrNull { it.type == FileType.PREPROCESSOR } + return if (file != null) "$modelsDir/${file.filename}" else "" + } - fun isPresetDownloaded(preset: WhisperModelPreset): Boolean { - val modelExists = File("$modelsDir/${preset.modelFile.filename}").exists() - val sharedExist = SHARED_FILES.all { File("$modelsDir/${it.filename}").exists() } - return modelExists && sharedExist + fun isPresetDownloaded(preset: AsrModelPreset): Boolean { + return preset.files.all { File("$modelsDir/${it.filename}").exists() } } fun downloadSelectedPreset() { @@ -175,18 +251,8 @@ class ModelDownloadViewModel : ViewModel() { val preset = getSelectedPreset() - val filesToDownload = mutableListOf() - - // Add shared files if not already present - for (shared in SHARED_FILES) { - if (!File("$modelsDir/${shared.filename}").exists()) { - filesToDownload.add(shared) - } - } - - // Add model file if not already present - if (!File("$modelsDir/${preset.modelFile.filename}").exists()) { - filesToDownload.add(preset.modelFile) + val filesToDownload = preset.files.filter { file -> + !File("$modelsDir/${file.filename}").exists() } if (filesToDownload.isEmpty()) { @@ -235,7 +301,7 @@ class ModelDownloadViewModel : ViewModel() { } private suspend fun downloadFile( - fileInfo: ModelFileInfo, + fileInfo: PresetFile, targetFile: File ): Boolean = withContext(Dispatchers.IO) { try { diff --git a/asr/android/shared/lib/src/main/java/com/example/asr/ModelSettings.kt b/asr/android/AsrApp/app/src/main/java/com/example/asrapp/ModelSettings.kt similarity index 80% rename from asr/android/shared/lib/src/main/java/com/example/asr/ModelSettings.kt rename to asr/android/AsrApp/app/src/main/java/com/example/asrapp/ModelSettings.kt index e0ce02f4..23bffdd4 100644 --- a/asr/android/shared/lib/src/main/java/com/example/asr/ModelSettings.kt +++ b/asr/android/AsrApp/app/src/main/java/com/example/asrapp/ModelSettings.kt @@ -1,4 +1,12 @@ -package com.example.asr +package com.example.asrapp + +/** + * The type of ASR model being used, which determines inference behavior. + */ +enum class ModelType { + WHISPER, + PARAKEET +} /** * Data class representing the model file settings for ASR inference. @@ -8,12 +16,14 @@ package com.example.asr * @param preprocessorPath Optional path to the preprocessor model (.pte file). * If empty, raw WAV audio will be used directly. * @param dataPath Optional path to external data file (.ptd file) + * @param modelType The type of ASR model (WHISPER or PARAKEET) */ data class ModelSettings( val modelPath: String = "", val tokenizerPath: String = "", val preprocessorPath: String = "", - val dataPath: String = "" + val dataPath: String = "", + val modelType: ModelType = ModelType.PARAKEET ) { /** * Check if the minimum required files are set for inference. diff --git a/asr/android/shared/lib/src/main/java/com/example/asr/ModelSettingsScreen.kt b/asr/android/AsrApp/app/src/main/java/com/example/asrapp/ModelSettingsScreen.kt similarity index 87% rename from asr/android/shared/lib/src/main/java/com/example/asr/ModelSettingsScreen.kt rename to asr/android/AsrApp/app/src/main/java/com/example/asrapp/ModelSettingsScreen.kt index 536ee6b7..dd09269b 100644 --- a/asr/android/shared/lib/src/main/java/com/example/asr/ModelSettingsScreen.kt +++ b/asr/android/AsrApp/app/src/main/java/com/example/asrapp/ModelSettingsScreen.kt @@ -1,4 +1,4 @@ -package com.example.asr +package com.example.asrapp import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -14,14 +14,18 @@ import androidx.compose.ui.unit.dp * Settings screen for selecting model files. * * @param showPreprocessor Whether to show the preprocessor file selection row. - * Set to true for Whisper, false for Parakeet. + * Defaults to auto-deriving from the current model type. + * @param showModelTypeSelector Whether to show the model type selector (Whisper/Parakeet). + * @param onModelTypeChanged Callback when the user changes the model type. */ @Composable fun ModelSettingsScreen( viewModel: ModelSettingsViewModel, onBackClick: () -> Unit, onDownloadClick: () -> Unit, - showPreprocessor: Boolean = false + showPreprocessor: Boolean = viewModel.modelSettings.modelType == ModelType.WHISPER, + showModelTypeSelector: Boolean = false, + onModelTypeChanged: ((ModelType) -> Unit)? = null ) { var showModelDialog by remember { mutableStateOf(false) } var showTokenizerDialog by remember { mutableStateOf(false) } @@ -43,6 +47,41 @@ fun ModelSettingsScreen( modifier = Modifier.padding(bottom = 24.dp) ) + // Model type selector + if (showModelTypeSelector) { + Text( + text = "Model Type", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + ModelType.entries.forEach { type -> + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = viewModel.modelSettings.modelType == type, + onClick = { + viewModel.selectModelType(type) + onModelTypeChanged?.invoke(type) + } + ) + Text( + text = type.name.lowercase() + .replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + } + // Model file selection FileSelectionRow( label = "Model File (.pte)", diff --git a/asr/android/shared/lib/src/main/java/com/example/asr/ModelSettingsViewModel.kt b/asr/android/AsrApp/app/src/main/java/com/example/asrapp/ModelSettingsViewModel.kt similarity index 85% rename from asr/android/shared/lib/src/main/java/com/example/asr/ModelSettingsViewModel.kt rename to asr/android/AsrApp/app/src/main/java/com/example/asrapp/ModelSettingsViewModel.kt index b74c90c9..e6f34106 100644 --- a/asr/android/shared/lib/src/main/java/com/example/asr/ModelSettingsViewModel.kt +++ b/asr/android/AsrApp/app/src/main/java/com/example/asrapp/ModelSettingsViewModel.kt @@ -1,4 +1,4 @@ -package com.example.asr +package com.example.asrapp import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -43,12 +43,22 @@ class ModelSettingsViewModel : ViewModel() { * Initialize the ViewModel with app-specific configuration. * * @param defaultDirectory The default directory to scan for model files - * (e.g., "/data/local/tmp/whisper" or "/data/local/tmp/parakeet") - * @param supportsPreprocessor Whether the app supports preprocessor file selection + * (e.g., "/data/local/tmp/asr") + * @param modelType The type of ASR model, used to derive preprocessor support */ - fun initialize(defaultDirectory: String, supportsPreprocessor: Boolean = false) { + fun initialize(defaultDirectory: String, modelType: ModelType = ModelType.PARAKEET) { this.defaultDirectory = defaultDirectory - this.supportsPreprocessor = supportsPreprocessor + this.supportsPreprocessor = (modelType == ModelType.WHISPER) + modelSettings = modelSettings.copy(modelType = modelType) + refreshFileLists() + } + + /** + * Change the model type and update preprocessor support accordingly. + */ + fun selectModelType(type: ModelType) { + modelSettings = modelSettings.copy(modelType = type) + supportsPreprocessor = (type == ModelType.WHISPER) refreshFileLists() } @@ -56,7 +66,7 @@ class ModelSettingsViewModel : ViewModel() { * Set the app-internal storage directory so we can scan it too. * * @param dir The full path to the app storage directory - * (e.g., "${filesDir}/whisper" or "${filesDir}/parakeet") + * (e.g., "${filesDir}/asr") */ fun setAppStorageDirectory(dir: String) { appStorageDirectory = dir diff --git a/asr/android/shared/lib/src/main/java/com/example/asr/ui/theme/AsrTheme.kt b/asr/android/AsrApp/app/src/main/java/com/example/asrapp/ui/theme/AsrTheme.kt similarity index 96% rename from asr/android/shared/lib/src/main/java/com/example/asr/ui/theme/AsrTheme.kt rename to asr/android/AsrApp/app/src/main/java/com/example/asrapp/ui/theme/AsrTheme.kt index a98f545f..01224ce4 100644 --- a/asr/android/shared/lib/src/main/java/com/example/asr/ui/theme/AsrTheme.kt +++ b/asr/android/AsrApp/app/src/main/java/com/example/asrapp/ui/theme/AsrTheme.kt @@ -1,4 +1,4 @@ -package com.example.asr.ui.theme +package com.example.asrapp.ui.theme import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme diff --git a/parakeet/android/ParakeetApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/asr/android/AsrApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml similarity index 100% rename from parakeet/android/ParakeetApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml rename to asr/android/AsrApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml diff --git a/parakeet/android/ParakeetApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/asr/android/AsrApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml similarity index 100% rename from parakeet/android/ParakeetApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml rename to asr/android/AsrApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml diff --git a/parakeet/android/ParakeetApp/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/asr/android/AsrApp/app/src/main/res/mipmap-hdpi/ic_launcher.webp similarity index 100% rename from parakeet/android/ParakeetApp/app/src/main/res/mipmap-hdpi/ic_launcher.webp rename to asr/android/AsrApp/app/src/main/res/mipmap-hdpi/ic_launcher.webp diff --git a/parakeet/android/ParakeetApp/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/asr/android/AsrApp/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp similarity index 100% rename from parakeet/android/ParakeetApp/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp rename to asr/android/AsrApp/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp diff --git a/parakeet/android/ParakeetApp/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/asr/android/AsrApp/app/src/main/res/mipmap-mdpi/ic_launcher.webp similarity index 100% rename from parakeet/android/ParakeetApp/app/src/main/res/mipmap-mdpi/ic_launcher.webp rename to asr/android/AsrApp/app/src/main/res/mipmap-mdpi/ic_launcher.webp diff --git a/parakeet/android/ParakeetApp/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/asr/android/AsrApp/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp similarity index 100% rename from parakeet/android/ParakeetApp/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp rename to asr/android/AsrApp/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp diff --git a/parakeet/android/ParakeetApp/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/asr/android/AsrApp/app/src/main/res/mipmap-xhdpi/ic_launcher.webp similarity index 100% rename from parakeet/android/ParakeetApp/app/src/main/res/mipmap-xhdpi/ic_launcher.webp rename to asr/android/AsrApp/app/src/main/res/mipmap-xhdpi/ic_launcher.webp diff --git a/parakeet/android/ParakeetApp/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/asr/android/AsrApp/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp similarity index 100% rename from parakeet/android/ParakeetApp/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp rename to asr/android/AsrApp/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp diff --git a/parakeet/android/ParakeetApp/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/asr/android/AsrApp/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp similarity index 100% rename from parakeet/android/ParakeetApp/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp rename to asr/android/AsrApp/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp diff --git a/parakeet/android/ParakeetApp/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/asr/android/AsrApp/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp similarity index 100% rename from parakeet/android/ParakeetApp/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp rename to asr/android/AsrApp/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp diff --git a/parakeet/android/ParakeetApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/asr/android/AsrApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp similarity index 100% rename from parakeet/android/ParakeetApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp rename to asr/android/AsrApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp diff --git a/parakeet/android/ParakeetApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/asr/android/AsrApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp similarity index 100% rename from parakeet/android/ParakeetApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp rename to asr/android/AsrApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp diff --git a/asr/android/AsrApp/app/src/main/res/values/strings.xml b/asr/android/AsrApp/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..2ff600bb --- /dev/null +++ b/asr/android/AsrApp/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + ASR App + diff --git a/asr/android/shared/lib/src/test/java/com/example/asr/ModelSettingsTest.kt b/asr/android/AsrApp/app/src/test/java/com/example/asrapp/ModelSettingsTest.kt similarity index 77% rename from asr/android/shared/lib/src/test/java/com/example/asr/ModelSettingsTest.kt rename to asr/android/AsrApp/app/src/test/java/com/example/asrapp/ModelSettingsTest.kt index d51f6508..58fbfe9e 100644 --- a/asr/android/shared/lib/src/test/java/com/example/asr/ModelSettingsTest.kt +++ b/asr/android/AsrApp/app/src/test/java/com/example/asrapp/ModelSettingsTest.kt @@ -1,4 +1,4 @@ -package com.example.asr +package com.example.asrapp import org.junit.Assert.* import org.junit.Test @@ -121,4 +121,38 @@ class ModelSettingsTest { assertEquals("/path/to/preprocess.pte", updated.preprocessorPath) assertEquals("", original.preprocessorPath) // Original unchanged } + + @Test + fun `modelType defaults to PARAKEET`() { + val settings = ModelSettings() + assertEquals(ModelType.PARAKEET, settings.modelType) + } + + @Test + fun `modelType can be set to WHISPER`() { + val settings = ModelSettings(modelType = ModelType.WHISPER) + assertEquals(ModelType.WHISPER, settings.modelType) + } + + @Test + fun `copy preserves modelType`() { + val original = ModelSettings( + modelPath = "/path/to/model.pte", + tokenizerPath = "/path/to/tokenizer.json", + modelType = ModelType.WHISPER + ) + val updated = original.copy(modelPath = "/path/to/other.pte") + + assertEquals(ModelType.WHISPER, updated.modelType) + assertEquals("/path/to/other.pte", updated.modelPath) + } + + @Test + fun `copy can change modelType`() { + val original = ModelSettings(modelType = ModelType.WHISPER) + val updated = original.copy(modelType = ModelType.PARAKEET) + + assertEquals(ModelType.WHISPER, original.modelType) + assertEquals(ModelType.PARAKEET, updated.modelType) + } } diff --git a/asr/android/shared/lib/src/test/java/com/example/asr/ModelSettingsViewModelTest.kt b/asr/android/AsrApp/app/src/test/java/com/example/asrapp/ModelSettingsViewModelTest.kt similarity index 99% rename from asr/android/shared/lib/src/test/java/com/example/asr/ModelSettingsViewModelTest.kt rename to asr/android/AsrApp/app/src/test/java/com/example/asrapp/ModelSettingsViewModelTest.kt index 6c122cbb..413c5496 100644 --- a/asr/android/shared/lib/src/test/java/com/example/asr/ModelSettingsViewModelTest.kt +++ b/asr/android/AsrApp/app/src/test/java/com/example/asrapp/ModelSettingsViewModelTest.kt @@ -1,4 +1,4 @@ -package com.example.asr +package com.example.asrapp import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue diff --git a/parakeet/android/ParakeetApp/build.gradle.kts b/asr/android/AsrApp/build.gradle.kts similarity index 100% rename from parakeet/android/ParakeetApp/build.gradle.kts rename to asr/android/AsrApp/build.gradle.kts diff --git a/parakeet/android/ParakeetApp/gradle.properties b/asr/android/AsrApp/gradle.properties similarity index 100% rename from parakeet/android/ParakeetApp/gradle.properties rename to asr/android/AsrApp/gradle.properties diff --git a/whisper/android/WhisperApp/gradle/libs.versions.toml b/asr/android/AsrApp/gradle/libs.versions.toml similarity index 99% rename from whisper/android/WhisperApp/gradle/libs.versions.toml rename to asr/android/AsrApp/gradle/libs.versions.toml index caafb09e..b03b80b7 100644 --- a/whisper/android/WhisperApp/gradle/libs.versions.toml +++ b/asr/android/AsrApp/gradle/libs.versions.toml @@ -31,4 +31,3 @@ androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } - diff --git a/parakeet/android/ParakeetApp/gradle/wrapper/gradle-wrapper.jar b/asr/android/AsrApp/gradle/wrapper/gradle-wrapper.jar similarity index 100% rename from parakeet/android/ParakeetApp/gradle/wrapper/gradle-wrapper.jar rename to asr/android/AsrApp/gradle/wrapper/gradle-wrapper.jar diff --git a/asr/android/shared/gradle/wrapper/gradle-wrapper.properties b/asr/android/AsrApp/gradle/wrapper/gradle-wrapper.properties similarity index 100% rename from asr/android/shared/gradle/wrapper/gradle-wrapper.properties rename to asr/android/AsrApp/gradle/wrapper/gradle-wrapper.properties diff --git a/parakeet/android/ParakeetApp/gradlew b/asr/android/AsrApp/gradlew similarity index 100% rename from parakeet/android/ParakeetApp/gradlew rename to asr/android/AsrApp/gradlew diff --git a/parakeet/android/ParakeetApp/gradlew.bat b/asr/android/AsrApp/gradlew.bat similarity index 100% rename from parakeet/android/ParakeetApp/gradlew.bat rename to asr/android/AsrApp/gradlew.bat diff --git a/asr/android/shared/settings.gradle.kts b/asr/android/AsrApp/settings.gradle.kts similarity index 91% rename from asr/android/shared/settings.gradle.kts rename to asr/android/AsrApp/settings.gradle.kts index 32872311..5c1aef0a 100644 --- a/asr/android/shared/settings.gradle.kts +++ b/asr/android/AsrApp/settings.gradle.kts @@ -19,5 +19,5 @@ dependencyResolutionManagement { } } -rootProject.name = "asr-shared" -include(":lib") +rootProject.name = "ASR App" +include(":app") diff --git a/asr/android/shared/build.gradle.kts b/asr/android/shared/build.gradle.kts deleted file mode 100644 index 13e810ac..00000000 --- a/asr/android/shared/build.gradle.kts +++ /dev/null @@ -1,6 +0,0 @@ -// Top-level build file where you can add configuration options common to all sub-projects/modules. -plugins { - alias(libs.plugins.android.library) apply false - alias(libs.plugins.kotlin.android) apply false - alias(libs.plugins.kotlin.compose) apply false -} diff --git a/asr/android/shared/gradle/libs.versions.toml b/asr/android/shared/gradle/libs.versions.toml deleted file mode 100644 index 447280ef..00000000 --- a/asr/android/shared/gradle/libs.versions.toml +++ /dev/null @@ -1,33 +0,0 @@ -[versions] -agp = "8.9.0" -kotlin = "2.0.21" -coreKtx = "1.16.0" -junit = "4.13.2" -junitVersion = "1.3.0" -espressoCore = "3.7.0" -lifecycleRuntimeKtx = "2.9.2" -activityCompose = "1.10.1" -composeBom = "2024.09.00" -appcompat = "1.7.1" - -[libraries] -androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } -junit = { group = "junit", name = "junit", version.ref = "junit" } -androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } -androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } -androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } -androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } -androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } -androidx-ui = { group = "androidx.compose.ui", name = "ui" } -androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } -androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } -androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } -androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } -androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } -androidx-material3 = { group = "androidx.compose.material3", name = "material3" } -androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } - -[plugins] -android-library = { id = "com.android.library", version.ref = "agp" } -kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } -kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } diff --git a/asr/android/shared/lib/build.gradle.kts b/asr/android/shared/lib/build.gradle.kts deleted file mode 100644 index 55912758..00000000 --- a/asr/android/shared/lib/build.gradle.kts +++ /dev/null @@ -1,35 +0,0 @@ -plugins { - alias(libs.plugins.android.library) - alias(libs.plugins.kotlin.android) - alias(libs.plugins.kotlin.compose) -} - -android { - namespace = "com.example.asr" - compileSdk = 35 - - defaultConfig { - minSdk = 24 - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } - kotlinOptions { - jvmTarget = "11" - } - buildFeatures { - compose = true - } -} - -dependencies { - implementation(libs.androidx.core.ktx) - implementation(libs.androidx.lifecycle.runtime.ktx) - implementation(platform(libs.androidx.compose.bom)) - implementation(libs.androidx.ui) - implementation(libs.androidx.ui.graphics) - implementation(libs.androidx.material3) - testImplementation(libs.junit) -} diff --git a/asr/android/shared/lib/src/main/AndroidManifest.xml b/asr/android/shared/lib/src/main/AndroidManifest.xml deleted file mode 100644 index 8072ee00..00000000 --- a/asr/android/shared/lib/src/main/AndroidManifest.xml +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/parakeet/android/ParakeetApp/README.md b/parakeet/android/ParakeetApp/README.md deleted file mode 100644 index 41575f41..00000000 --- a/parakeet/android/ParakeetApp/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# Parakeet Demo App - -This app demonstrates running the Parakeet TDT speech recognition model on Android using ExecuTorch. - -## Download ExecuTorch AAR - -Download the prebuilt ExecuTorch AAR (with Parakeet JNI bindings) and place it in `app/libs/`: - -```bash -mkdir -p app/libs -curl -L -o app/libs/executorch.aar https://gha-artifacts.s3.amazonaws.com/pytorch/executorch/21934561658/artifacts/executorch.aar -``` - -## Export Model Files - -Export the model `.pte` and tokenizer files following the instructions at: -https://github.com/pytorch/executorch/tree/main/examples/models/parakeet - -This app requires a model `.pte` and a tokenizer `.model` file. - -## Run the App - -1. Download the ExecuTorch AAR (see above) -2. Open ParakeetApp in Android Studio -3. Build and run on device - -## Download Models - -The app includes a built-in download screen to fetch the Parakeet TDT 0.6B (INT4) model from HuggingFace: -- Model: `parakeet_int4.pte` -- Tokenizer: `tokenizer.model` - -Alternatively, push files manually: -```bash -adb push model.pte /data/local/tmp/parakeet/ -adb push tokenizer.model /data/local/tmp/parakeet/ -``` diff --git a/parakeet/android/ParakeetApp/app/.gitignore b/parakeet/android/ParakeetApp/app/.gitignore deleted file mode 100644 index 796b96d1..00000000 --- a/parakeet/android/ParakeetApp/app/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build diff --git a/parakeet/android/ParakeetApp/app/build.gradle.kts b/parakeet/android/ParakeetApp/app/build.gradle.kts deleted file mode 100644 index 0ff3d033..00000000 --- a/parakeet/android/ParakeetApp/app/build.gradle.kts +++ /dev/null @@ -1,60 +0,0 @@ -plugins { - alias(libs.plugins.android.application) - alias(libs.plugins.kotlin.android) - alias(libs.plugins.kotlin.compose) -} - -val useLocalAar: Boolean? = (project.findProperty("useLocalAar") as? String)?.toBoolean() - -android { - namespace = "com.example.parakeetapp" - compileSdk = 35 - - defaultConfig { - applicationId = "com.example.parakeetapp" - minSdk = 24 - targetSdk = 35 - versionCode = 1 - versionName = "1.0" - } - - buildTypes { - release { - isMinifyEnabled = false - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - } - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } - kotlinOptions { - jvmTarget = "11" - } - buildFeatures { - compose = true - } -} - -dependencies { - implementation(libs.androidx.core.ktx) - implementation(libs.androidx.lifecycle.runtime.ktx) - implementation(libs.androidx.activity.compose) - implementation(platform(libs.androidx.compose.bom)) - implementation(libs.androidx.ui) - implementation(libs.androidx.ui.graphics) - implementation(libs.androidx.ui.tooling.preview) - implementation(libs.androidx.material3) - implementation("com.example.asr:lib") - testImplementation(libs.junit) - debugImplementation(libs.androidx.ui.tooling) - if (useLocalAar == true) { - implementation(files("libs/executorch.aar")) - } else { - implementation("org.pytorch:executorch-android:1.1.0") - } - implementation("com.facebook.fbjni:fbjni:0.5.1") -} diff --git a/parakeet/android/ParakeetApp/app/src/main/java/com/example/parakeetapp/MainActivity.kt b/parakeet/android/ParakeetApp/app/src/main/java/com/example/parakeetapp/MainActivity.kt deleted file mode 100644 index 1efb16c7..00000000 --- a/parakeet/android/ParakeetApp/app/src/main/java/com/example/parakeetapp/MainActivity.kt +++ /dev/null @@ -1,742 +0,0 @@ -package com.example.parakeetapp - -import android.Manifest -import android.content.pm.PackageManager -import android.media.AudioFormat -import android.media.AudioRecord -import android.media.MediaRecorder -import android.os.Bundle -import android.system.ErrnoException -import android.system.Os -import android.util.Log -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.PressInteraction -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.RadioButton -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.core.content.ContextCompat -import androidx.lifecycle.ViewModelProvider -import com.example.asr.ModelSettings -import com.example.asr.ModelSettingsScreen -import com.example.asr.ModelSettingsViewModel -import com.example.asr.ui.theme.AsrTheme -import org.pytorch.executorch.extension.parakeet.ParakeetModule -import java.io.File -import java.io.FileOutputStream -import java.io.IOException -import java.io.OutputStream - -class MainActivity : ComponentActivity() { - - companion object { - private const val TAG = "MainActivity" - } - - private var transcriptionOutput by mutableStateOf("") - private var buttonText by mutableStateOf("Hold to Record") - private var buttonEnabled by mutableStateOf(true) - private var statusText by mutableStateOf("") - private var currentScreen by mutableStateOf(Screen.MAIN) - private var showWavFileDialog by mutableStateOf(false) - - private var isRecording = false - private var audioRecord: AudioRecord? = null - private var recordingThread: Thread? = null - - private val sampleRate = 16000 - private val channelConfig = AudioFormat.CHANNEL_IN_MONO - private val audioFormat = AudioFormat.ENCODING_PCM_16BIT - - private val bufferSize = AudioRecord.getMinBufferSize( - sampleRate, - channelConfig, - audioFormat - ) - - private lateinit var viewModel: ModelSettingsViewModel - private lateinit var downloadViewModel: ModelDownloadViewModel - - enum class Screen { - DOWNLOAD, - MAIN, - SETTINGS - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - try { - Os.setenv("ADSP_LIBRARY_PATH", applicationInfo.nativeLibraryDir, true) - Os.setenv("LD_LIBRARY_PATH", applicationInfo.nativeLibraryDir, true) - } catch (e: ErrnoException) { - finish() - } - - // Initialize view models - viewModel = ViewModelProvider(this)[ModelSettingsViewModel::class.java] - viewModel.setAppStorageDirectory("${filesDir.absolutePath}/parakeet") - viewModel.initialize("/data/local/tmp/parakeet") - - downloadViewModel = ViewModelProvider(this)[ModelDownloadViewModel::class.java] - downloadViewModel.initialize(filesDir.absolutePath) - - // If the first preset is already downloaded, auto-select its paths - val firstPreset = ModelDownloadViewModel.MODEL_PRESETS[0] - if (downloadViewModel.isPresetDownloaded(firstPreset)) { - downloadViewModel.selectPreset(0) - applyDownloadedModelPaths() - } - - // Check if minimum buffer size is valid - if (bufferSize == AudioRecord.ERROR_BAD_VALUE || bufferSize == AudioRecord.ERROR) { - Log.e(TAG, "Invalid buffer size") - statusText = "Audio recording not supported on this device" - } - - setContent { - AsrTheme { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background - ) { - when (currentScreen) { - Screen.DOWNLOAD -> { - ModelDownloadScreen( - downloadViewModel = downloadViewModel, - onDownloadComplete = { - applyDownloadedModelPaths() - viewModel.refreshFileLists() - currentScreen = Screen.SETTINGS - }, - onSkip = { - currentScreen = Screen.SETTINGS - } - ) - } - Screen.MAIN -> { - ParakeetScreen( - buttonText = buttonText, - buttonEnabled = buttonEnabled && viewModel.isReadyForInference(), - statusText = statusText, - transcriptionResult = transcriptionOutput, - modelSettings = viewModel.modelSettings, - defaultDirectory = viewModel.defaultDirectory, - availableWavFiles = viewModel.availableWavFiles, - showWavFileDialog = showWavFileDialog, - onRecordStart = { startRecording() }, - onRecordStop = { stopRecording() }, - onUseWavFileClick = { - viewModel.refreshFileLists() - showWavFileDialog = true - }, - onWavFileSelected = { wavPath -> - showWavFileDialog = false - runParakeetFromFile(wavPath) - }, - onWavDialogDismiss = { showWavFileDialog = false }, - onSettingsClick = { - viewModel.refreshFileLists() - currentScreen = Screen.SETTINGS - } - ) - } - Screen.SETTINGS -> { - ModelSettingsScreen( - viewModel = viewModel, - onBackClick = { currentScreen = Screen.MAIN }, - onDownloadClick = { currentScreen = Screen.DOWNLOAD } - ) - } - } - } - } - } - } - - private fun applyDownloadedModelPaths() { - viewModel.selectModel(downloadViewModel.getModelPath()) - viewModel.selectTokenizer(downloadViewModel.getTokenizerPath()) - } - - /** - * Run Parakeet inference on the recorded audio file. - */ - private fun runParakeet() { - val wavFile = File(getExternalFilesDir(null), "audio_record.wav") - runParakeetOnWavFile(wavFile.absolutePath) - } - - /** - * Run Parakeet inference on a WAV file. - */ - private fun runParakeetFromFile(wavFilePath: String) { - buttonEnabled = false - statusText = "Loading WAV file..." - - Thread { - try { - runParakeetOnWavFile(wavFilePath) - } catch (e: Exception) { - Log.e(TAG, "Error processing WAV file", e) - runOnUiThread { - statusText = "Error: ${e.message}" - buttonEnabled = true - } - } - }.start() - } - - /** - * Common method to run Parakeet on a WAV file path. - */ - private fun runParakeetOnWavFile(wavFilePath: String) { - val settings = viewModel.modelSettings - - if (!settings.isValid()) { - runOnUiThread { - statusText = "Please select model and tokenizer in Settings" - buttonEnabled = true - } - return - } - - runOnUiThread { - transcriptionOutput = "" - statusText = "Loading model..." - buttonText = "Transcribing..." - buttonEnabled = false - } - - val parakeetModule = ParakeetModule( - modelPath = settings.modelPath, - tokenizerPath = settings.tokenizerPath, - dataPath = settings.dataPath.ifBlank { null } - ) - - Log.v(TAG, "Starting transcribe for: $wavFilePath") - runOnUiThread { - statusText = "Transcribing..." - } - val startTime = System.currentTimeMillis() - val result = parakeetModule.transcribe(wavFilePath) - val elapsedTime = System.currentTimeMillis() - startTime - val elapsedSeconds = elapsedTime / 1000.0 - Log.v(TAG, "Finished transcribe in ${elapsedSeconds}s") - - parakeetModule.close() - - runOnUiThread { - transcriptionOutput = result - statusText = "Transcription complete (%.2fs)".format(elapsedSeconds) - buttonText = "Hold to Record" - buttonEnabled = true - } - } - - private fun startRecording() { - when { - ContextCompat.checkSelfPermission( - this, - Manifest.permission.RECORD_AUDIO - ) == PackageManager.PERMISSION_GRANTED -> { - // Permission already granted, start recording - try { - audioRecord = AudioRecord( - MediaRecorder.AudioSource.MIC, - sampleRate, - channelConfig, - audioFormat, - bufferSize - ) - - if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) { - Log.e(TAG, "AudioRecord initialization failed") - statusText = "Failed to initialize audio recorder" - return - } - - audioRecord?.startRecording() - isRecording = true - - buttonText = "Recording..." - - val pcmFile = File(getExternalFilesDir(null), "audio_record.pcm") - - recordingThread = Thread { - try { - val os = FileOutputStream(pcmFile) - val buffer = ByteArray(bufferSize) - - while (isRecording) { - val read = audioRecord?.read(buffer, 0, buffer.size) ?: 0 - if (read > 0) { - os.write(buffer, 0, read) - } - } - - os.close() - - runOnUiThread { - writeWavFile(pcmFile) - statusText = "Recording saved" - runParakeet() - } - - } catch (e: IOException) { - Log.e(TAG, "Recording failed", e) - runOnUiThread { - statusText = "Recording failed" - } - } - } - - recordingThread?.start() - - } catch (e: Exception) { - Log.e(TAG, "Failed to start recording", e) - statusText = "Failed to start recording" - } - } - - shouldShowRequestPermissionRationale(Manifest.permission.RECORD_AUDIO) -> { - statusText = "Audio recording permission is needed to record audio" - requestPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) - } - - else -> { - requestPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) - } - } - } - - private fun stopRecording() { - if (!isRecording) return - - isRecording = false - - try { - audioRecord?.stop() - audioRecord?.release() - } catch (e: Exception) { - Log.e(TAG, "Error stopping recording", e) - } - - audioRecord = null - buttonText = "Processing..." - buttonEnabled = false - - recordingThread?.join() - recordingThread = null - } - - private fun writeWavFile(pcmFile: File) { - try { - val wavFile = File(getExternalFilesDir(null), "audio_record.wav") - val pcmData = pcmFile.readBytes() - - val wavOut = FileOutputStream(wavFile) - - // Write WAV header for 16-bit mono audio at 16 kHz - writeWavHeader(wavOut, pcmData.size.toLong(), sampleRate, 1, 16) - wavOut.write(pcmData) - wavOut.flush() - wavOut.fd.sync() - wavOut.close() - - pcmFile.delete() - - Log.i(TAG, "WAV file saved: ${wavFile.absolutePath}") - - } catch (e: IOException) { - Log.e(TAG, "Failed to write WAV file", e) - } - } - - private fun writeWavHeader( - out: OutputStream, - totalAudioLen: Long, - sampleRate: Int, - channels: Int, - bitsPerSample: Int - ) { - val byteRate = sampleRate * channels * bitsPerSample / 8 - val blockAlign = channels * bitsPerSample / 8 - val totalDataLen = totalAudioLen + 36 - - val header = ByteArray(44) - - // RIFF header - header[0] = 'R'.code.toByte() - header[1] = 'I'.code.toByte() - header[2] = 'F'.code.toByte() - header[3] = 'F'.code.toByte() - - // File size (little-endian) - header[4] = (totalDataLen and 0xff).toByte() - header[5] = ((totalDataLen shr 8) and 0xff).toByte() - header[6] = ((totalDataLen shr 16) and 0xff).toByte() - header[7] = ((totalDataLen shr 24) and 0xff).toByte() - - // WAVE header - header[8] = 'W'.code.toByte() - header[9] = 'A'.code.toByte() - header[10] = 'V'.code.toByte() - header[11] = 'E'.code.toByte() - - // fmt chunk - header[12] = 'f'.code.toByte() - header[13] = 'm'.code.toByte() - header[14] = 't'.code.toByte() - header[15] = ' '.code.toByte() - - // fmt chunk size (16 for PCM) - header[16] = 16 - header[17] = 0 - header[18] = 0 - header[19] = 0 - - // Audio format (1 for PCM) - header[20] = 1 - header[21] = 0 - - // Number of channels - header[22] = channels.toByte() - header[23] = 0 - - // Sample rate (little-endian) - header[24] = (sampleRate and 0xff).toByte() - header[25] = ((sampleRate shr 8) and 0xff).toByte() - header[26] = ((sampleRate shr 16) and 0xff).toByte() - header[27] = ((sampleRate shr 24) and 0xff).toByte() - - // Byte rate (little-endian) - header[28] = (byteRate and 0xff).toByte() - header[29] = ((byteRate shr 8) and 0xff).toByte() - header[30] = ((byteRate shr 16) and 0xff).toByte() - header[31] = ((byteRate shr 24) and 0xff).toByte() - - // Block align - header[32] = blockAlign.toByte() - header[33] = 0 - - // Bits per sample - header[34] = bitsPerSample.toByte() - header[35] = 0 - - // Data chunk header - header[36] = 'd'.code.toByte() - header[37] = 'a'.code.toByte() - header[38] = 't'.code.toByte() - header[39] = 'a'.code.toByte() - - // Data chunk size (little-endian) - header[40] = (totalAudioLen and 0xff).toByte() - header[41] = ((totalAudioLen shr 8) and 0xff).toByte() - header[42] = ((totalAudioLen shr 16) and 0xff).toByte() - header[43] = ((totalAudioLen shr 24) and 0xff).toByte() - - out.write(header, 0, 44) - } - - override fun onDestroy() { - super.onDestroy() - if (isRecording) { - stopRecording() - } - } - - private val requestPermissionLauncher = registerForActivityResult( - ActivityResultContracts.RequestPermission() - ) { isGranted: Boolean -> - if (isGranted) { - statusText = "Permission granted. Hold the button to record." - } else { - statusText = "Audio recording permission required" - } - } -} - -@Composable -fun ParakeetScreen( - buttonText: String, - buttonEnabled: Boolean, - statusText: String, - transcriptionResult: String, - modelSettings: ModelSettings, - defaultDirectory: String, - availableWavFiles: List, - showWavFileDialog: Boolean, - onRecordStart: () -> Unit, - onRecordStop: () -> Unit, - onUseWavFileClick: () -> Unit, - onWavFileSelected: (String) -> Unit, - onWavDialogDismiss: () -> Unit, - onSettingsClick: () -> Unit -) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Top - ) { - Spacer(modifier = Modifier.height(32.dp)) - - Text( - text = "Parakeet Demo", - style = MaterialTheme.typography.headlineMedium - ) - - Spacer(modifier = Modifier.height(24.dp)) - - // Model info card - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceVariant, - shape = MaterialTheme.shapes.medium - ) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = "Model Configuration", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(8.dp)) - - if (modelSettings.isValid()) { - Text( - text = "Model: ${modelSettings.modelPath.substringAfterLast('/')}", - style = MaterialTheme.typography.bodySmall - ) - Text( - text = "Tokenizer: ${modelSettings.tokenizerPath.substringAfterLast('/')}", - style = MaterialTheme.typography.bodySmall - ) - if (modelSettings.dataPath.isNotBlank()) { - Text( - text = "Data: ${modelSettings.dataPath.substringAfterLast('/')}", - style = MaterialTheme.typography.bodySmall - ) - } - } else { - Text( - text = "No model configured", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - Text( - text = "Please configure models in Settings", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - - Spacer(modifier = Modifier.height(16.dp)) - - // Audio input buttons - val interactionSource = remember { MutableInteractionSource() } - val currentOnRecordStart by rememberUpdatedState(onRecordStart) - val currentOnRecordStop by rememberUpdatedState(onRecordStop) - - LaunchedEffect(interactionSource) { - var isPressed = false - interactionSource.interactions.collect { interaction -> - when (interaction) { - is PressInteraction.Press -> { - isPressed = true - currentOnRecordStart() - } - is PressInteraction.Release -> { - if (isPressed) { - isPressed = false - currentOnRecordStop() - } - } - is PressInteraction.Cancel -> { - if (isPressed) { - isPressed = false - currentOnRecordStop() - } - } - } - } - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Button( - onClick = {}, - interactionSource = interactionSource, - enabled = buttonEnabled, - modifier = Modifier.weight(1f) - ) { - Text(text = buttonText) - } - - OutlinedButton( - onClick = onUseWavFileClick, - enabled = buttonEnabled, - modifier = Modifier.weight(1f) - ) { - Text(text = "Use WAV File") - } - } - - Spacer(modifier = Modifier.height(8.dp)) - - // Settings button - OutlinedButton( - onClick = onSettingsClick, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = "Settings") - } - - Spacer(modifier = Modifier.height(16.dp)) - - if (statusText.isNotEmpty()) { - Text( - text = statusText, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.secondary - ) - Spacer(modifier = Modifier.height(8.dp)) - } - - if (transcriptionResult.isNotEmpty()) { - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.primaryContainer, - shape = MaterialTheme.shapes.medium - ) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = "Transcription", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = transcriptionResult, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - } - } - } - } - - // WAV file selection dialog - if (showWavFileDialog) { - WavFileSelectionDialog( - files = availableWavFiles, - defaultDirectory = defaultDirectory, - onDismiss = onWavDialogDismiss, - onSelect = onWavFileSelected - ) - } -} - -@Composable -fun WavFileSelectionDialog( - files: List, - defaultDirectory: String, - onDismiss: () -> Unit, - onSelect: (String) -> Unit -) { - var selectedFile by remember { mutableStateOf(null) } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Select WAV File") }, - text = { - if (files.isEmpty()) { - Column { - Text("No WAV files found in $defaultDirectory") - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Use adb to push WAV files:\nadb push audio.wav $defaultDirectory/", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } else { - Column(modifier = Modifier.verticalScroll(rememberScrollState())) { - files.forEach { filePath -> - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - RadioButton( - selected = filePath == selectedFile, - onClick = { selectedFile = filePath } - ) - Spacer(modifier = Modifier.width(8.dp)) - Column { - Text( - text = filePath.substringAfterLast('/'), - style = MaterialTheme.typography.bodyMedium - ) - Text( - text = filePath, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - Spacer(modifier = Modifier.height(4.dp)) - } - } - } - }, - confirmButton = { - if (files.isNotEmpty()) { - TextButton( - onClick = { selectedFile?.let { onSelect(it) } }, - enabled = selectedFile != null - ) { - Text("Transcribe") - } - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text("Cancel") - } - } - ) -} diff --git a/parakeet/android/ParakeetApp/app/src/main/java/com/example/parakeetapp/ModelDownloadScreen.kt b/parakeet/android/ParakeetApp/app/src/main/java/com/example/parakeetapp/ModelDownloadScreen.kt deleted file mode 100644 index 9aa32836..00000000 --- a/parakeet/android/ParakeetApp/app/src/main/java/com/example/parakeetapp/ModelDownloadScreen.kt +++ /dev/null @@ -1,261 +0,0 @@ -package com.example.parakeetapp - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.RadioButton -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp - -@Composable -fun ModelDownloadScreen( - downloadViewModel: ModelDownloadViewModel, - onDownloadComplete: () -> Unit, - onSkip: () -> Unit -) { - val status = downloadViewModel.downloadStatus - val progress = downloadViewModel.downloadProgress - val currentFileName = downloadViewModel.currentFileName - val error = downloadViewModel.errorMessage - val selectedIndex = downloadViewModel.selectedPresetIndex - val isDownloading = status == DownloadStatus.DOWNLOADING - - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Text( - text = "Download Model", - style = MaterialTheme.typography.headlineMedium - ) - - Spacer(modifier = Modifier.height(24.dp)) - - // Preset selection - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceVariant, - shape = MaterialTheme.shapes.medium - ) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = "Select Model", - style = MaterialTheme.typography.titleSmall - ) - Spacer(modifier = Modifier.height(8.dp)) - - ModelDownloadViewModel.MODEL_PRESETS.forEachIndexed { index, preset -> - val alreadyDownloaded = downloadViewModel.isPresetDownloaded(preset) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - RadioButton( - selected = index == selectedIndex, - onClick = { - if (!isDownloading) { - downloadViewModel.selectPreset(index) - downloadViewModel.resetStatus() - } - }, - enabled = !isDownloading - ) - Spacer(modifier = Modifier.width(4.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = preset.displayName, - style = MaterialTheme.typography.bodyMedium - ) - Text( - text = preset.modelFile.filename, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - if (alreadyDownloaded) { - Text( - text = "✓", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary - ) - } - } - if (index < ModelDownloadViewModel.MODEL_PRESETS.size - 1) { - Spacer(modifier = Modifier.height(4.dp)) - } - } - } - } - - Spacer(modifier = Modifier.height(16.dp)) - - // Files to download - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceVariant, - shape = MaterialTheme.shapes.medium - ) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = "Files", - style = MaterialTheme.typography.titleSmall - ) - Spacer(modifier = Modifier.height(8.dp)) - - val preset = downloadViewModel.getSelectedPreset() - - // Tokenizer file - FileStatusRow(preset.tokenizerFile.description, preset.tokenizerFile.filename, downloadViewModel) - Spacer(modifier = Modifier.height(4.dp)) - - // Model file - FileStatusRow(preset.modelFile.description, preset.modelFile.filename, downloadViewModel) - - // Download progress - if (isDownloading) { - Spacer(modifier = Modifier.height(12.dp)) - LinearProgressIndicator( - progress = { progress }, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(modifier = Modifier.height(8.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "Downloading $currentFileName...", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - if (status == DownloadStatus.COMPLETED) { - Spacer(modifier = Modifier.height(12.dp)) - Text( - text = "All files downloaded!", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary - ) - } - - if (error != null) { - Spacer(modifier = Modifier.height(12.dp)) - Text( - text = error, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - } - } - } - - Spacer(modifier = Modifier.height(24.dp)) - - when (status) { - DownloadStatus.NOT_STARTED, DownloadStatus.FAILED -> { - Button( - onClick = { downloadViewModel.downloadSelectedPreset() }, - modifier = Modifier.fillMaxWidth() - ) { - Text(if (status == DownloadStatus.FAILED) "Retry Download" else "Download") - } - - Spacer(modifier = Modifier.height(8.dp)) - - OutlinedButton( - onClick = onSkip, - modifier = Modifier.fillMaxWidth() - ) { - Text("Back") - } - } - - DownloadStatus.DOWNLOADING -> { - Button( - onClick = {}, - enabled = false, - modifier = Modifier.fillMaxWidth() - ) { - Text("Downloading...") - } - } - - DownloadStatus.COMPLETED -> { - Button( - onClick = onDownloadComplete, - modifier = Modifier.fillMaxWidth() - ) { - Text("Continue") - } - - Spacer(modifier = Modifier.height(8.dp)) - - OutlinedButton( - onClick = { - downloadViewModel.resetStatus() - }, - modifier = Modifier.fillMaxWidth() - ) { - Text("Download Another Model") - } - } - } - } -} - -@Composable -private fun FileStatusRow( - description: String, - filename: String, - downloadViewModel: ModelDownloadViewModel -) { - val status = downloadViewModel.downloadStatus - val currentFileName = downloadViewModel.currentFileName - val modelsDir = downloadViewModel.getModelDir() - val fileExists = java.io.File("$modelsDir/$filename").exists() - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Column(modifier = Modifier.weight(1f)) { - Text(text = description, style = MaterialTheme.typography.bodyMedium) - Text( - text = filename, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Text( - text = when { - fileExists || status == DownloadStatus.COMPLETED -> "✓" - status == DownloadStatus.DOWNLOADING && currentFileName == filename -> "⬇" - else -> "○" - }, - style = MaterialTheme.typography.bodyMedium - ) - } -} diff --git a/parakeet/android/ParakeetApp/app/src/main/java/com/example/parakeetapp/ModelDownloadViewModel.kt b/parakeet/android/ParakeetApp/app/src/main/java/com/example/parakeetapp/ModelDownloadViewModel.kt deleted file mode 100644 index 1b60bfb6..00000000 --- a/parakeet/android/ParakeetApp/app/src/main/java/com/example/parakeetapp/ModelDownloadViewModel.kt +++ /dev/null @@ -1,212 +0,0 @@ -package com.example.parakeetapp - -import android.util.Log -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.File -import java.io.FileOutputStream -import java.net.HttpURLConnection -import java.net.URL - -data class ModelFileInfo( - val url: String, - val filename: String, - val description: String -) - -data class ParakeetModelPreset( - val id: String, - val displayName: String, - val modelFile: ModelFileInfo, - val tokenizerFile: ModelFileInfo -) - -enum class DownloadStatus { - NOT_STARTED, - DOWNLOADING, - COMPLETED, - FAILED -} - -class ModelDownloadViewModel : ViewModel() { - - companion object { - private const val TAG = "ModelDownloadViewModel" - private const val MODELS_SUBDIRECTORY = "parakeet" - - private const val PARAKEET_BASE_URL = - "https://huggingface.co/larryliu0820/parakeet-tdt-0.6b-v3-executorch/resolve/main/xnnpack/int4" - - val MODEL_PRESETS = listOf( - ParakeetModelPreset( - id = "parakeet_int4", - displayName = "Parakeet TDT 0.6B (INT4)", - modelFile = ModelFileInfo( - url = "$PARAKEET_BASE_URL/model.pte", - filename = "parakeet_int4.pte", - description = "Parakeet TDT 0.6B INT4 Model" - ), - tokenizerFile = ModelFileInfo( - url = "$PARAKEET_BASE_URL/tokenizer.model", - filename = "tokenizer.model", - description = "Tokenizer" - ) - ) - ) - } - - var downloadStatus by mutableStateOf(DownloadStatus.NOT_STARTED) - private set - - var downloadProgress by mutableFloatStateOf(0f) - private set - - var currentFileIndex by mutableIntStateOf(0) - private set - - var totalFileCount by mutableIntStateOf(0) - private set - - var currentFileName by mutableStateOf("") - private set - - var errorMessage by mutableStateOf(null) - private set - - var selectedPresetIndex by mutableIntStateOf(0) - private set - - private lateinit var modelsDir: String - - fun initialize(filesDir: String) { - modelsDir = filesDir + "/" + MODELS_SUBDIRECTORY - } - - fun getModelDir(): String = modelsDir - - fun selectPreset(index: Int) { - selectedPresetIndex = index - } - - fun getSelectedPreset(): ParakeetModelPreset = MODEL_PRESETS[selectedPresetIndex] - - fun getModelPath(): String = "$modelsDir/${getSelectedPreset().modelFile.filename}" - fun getTokenizerPath(): String = "$modelsDir/${getSelectedPreset().tokenizerFile.filename}" - - fun isPresetDownloaded(preset: ParakeetModelPreset): Boolean { - val modelExists = File("$modelsDir/${preset.modelFile.filename}").exists() - val tokenizerExists = File("$modelsDir/${preset.tokenizerFile.filename}").exists() - return modelExists && tokenizerExists - } - - fun downloadSelectedPreset() { - if (downloadStatus == DownloadStatus.DOWNLOADING) return - - val preset = getSelectedPreset() - - val filesToDownload = mutableListOf() - - // Add tokenizer file if not already present - if (!File("$modelsDir/${preset.tokenizerFile.filename}").exists()) { - filesToDownload.add(preset.tokenizerFile) - } - - // Add model file if not already present - if (!File("$modelsDir/${preset.modelFile.filename}").exists()) { - filesToDownload.add(preset.modelFile) - } - - if (filesToDownload.isEmpty()) { - downloadStatus = DownloadStatus.COMPLETED - return - } - - downloadStatus = DownloadStatus.DOWNLOADING - downloadProgress = 0f - currentFileIndex = 0 - totalFileCount = filesToDownload.size - errorMessage = null - - viewModelScope.launch { - try { - val dir = File(modelsDir) - if (!dir.exists()) { - dir.mkdirs() - } - - for ((index, fileInfo) in filesToDownload.withIndex()) { - currentFileIndex = index - currentFileName = fileInfo.filename - val targetFile = File("$modelsDir/${fileInfo.filename}") - - val success = downloadFile(fileInfo, targetFile) - if (!success) { - downloadStatus = DownloadStatus.FAILED - return@launch - } - downloadProgress = (index + 1).toFloat() / filesToDownload.size - } - - downloadStatus = DownloadStatus.COMPLETED - } catch (e: Exception) { - Log.e(TAG, "Download failed", e) - downloadStatus = DownloadStatus.FAILED - errorMessage = "Download failed: ${e.message}" - } - } - } - - fun resetStatus() { - downloadStatus = DownloadStatus.NOT_STARTED - errorMessage = null - } - - private suspend fun downloadFile( - fileInfo: ModelFileInfo, - targetFile: File - ): Boolean = withContext(Dispatchers.IO) { - try { - Log.i(TAG, "Downloading ${fileInfo.filename} from ${fileInfo.url}") - val url = URL(fileInfo.url) - val connection = url.openConnection() as HttpURLConnection - connection.requestMethod = "GET" - connection.instanceFollowRedirects = true - connection.connectTimeout = 30000 - connection.readTimeout = 30000 - connection.connect() - - if (connection.responseCode != HttpURLConnection.HTTP_OK) { - throw Exception("Server returned HTTP ${connection.responseCode}") - } - - val tempFile = File(targetFile.absolutePath + ".tmp") - connection.inputStream.use { input -> - FileOutputStream(tempFile).use { output -> - val buffer = ByteArray(8192) - var bytesRead: Int - while (input.read(buffer).also { bytesRead = it } != -1) { - output.write(buffer, 0, bytesRead) - } - } - } - tempFile.renameTo(targetFile) - - Log.i(TAG, "Downloaded ${fileInfo.filename} successfully") - true - } catch (e: Exception) { - Log.e(TAG, "Failed to download ${fileInfo.filename}", e) - withContext(Dispatchers.Main) { - errorMessage = "Failed to download ${fileInfo.filename}: ${e.message}" - } - false - } - } -} diff --git a/parakeet/android/ParakeetApp/app/src/main/res/drawable/ic_launcher_background.xml b/parakeet/android/ParakeetApp/app/src/main/res/drawable/ic_launcher_background.xml deleted file mode 100644 index 07d5da9c..00000000 --- a/parakeet/android/ParakeetApp/app/src/main/res/drawable/ic_launcher_background.xml +++ /dev/null @@ -1,170 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/parakeet/android/ParakeetApp/app/src/main/res/drawable/ic_launcher_foreground.xml b/parakeet/android/ParakeetApp/app/src/main/res/drawable/ic_launcher_foreground.xml deleted file mode 100644 index 2b068d11..00000000 --- a/parakeet/android/ParakeetApp/app/src/main/res/drawable/ic_launcher_foreground.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/parakeet/android/ParakeetApp/app/src/main/res/values/strings.xml b/parakeet/android/ParakeetApp/app/src/main/res/values/strings.xml deleted file mode 100644 index 95db2920..00000000 --- a/parakeet/android/ParakeetApp/app/src/main/res/values/strings.xml +++ /dev/null @@ -1,3 +0,0 @@ - - Parakeet App - diff --git a/parakeet/android/ParakeetApp/gradle/libs.versions.toml b/parakeet/android/ParakeetApp/gradle/libs.versions.toml deleted file mode 100644 index caafb09e..00000000 --- a/parakeet/android/ParakeetApp/gradle/libs.versions.toml +++ /dev/null @@ -1,34 +0,0 @@ -[versions] -agp = "8.9.0" -kotlin = "2.0.21" -coreKtx = "1.16.0" -junit = "4.13.2" -junitVersion = "1.3.0" -espressoCore = "3.7.0" -lifecycleRuntimeKtx = "2.9.2" -activityCompose = "1.10.1" -composeBom = "2024.09.00" -appcompat = "1.7.1" - -[libraries] -androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } -junit = { group = "junit", name = "junit", version.ref = "junit" } -androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } -androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } -androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } -androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } -androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } -androidx-ui = { group = "androidx.compose.ui", name = "ui" } -androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } -androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } -androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } -androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } -androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } -androidx-material3 = { group = "androidx.compose.material3", name = "material3" } -androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } - -[plugins] -android-application = { id = "com.android.application", version.ref = "agp" } -kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } -kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } - diff --git a/parakeet/android/ParakeetApp/gradle/wrapper/gradle-wrapper.properties b/parakeet/android/ParakeetApp/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index 3324d4d6..00000000 --- a/parakeet/android/ParakeetApp/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,6 +0,0 @@ -#Fri Aug 08 17:07:04 PDT 2025 -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists diff --git a/parakeet/android/ParakeetApp/settings.gradle.kts b/parakeet/android/ParakeetApp/settings.gradle.kts deleted file mode 100644 index 0080fd0f..00000000 --- a/parakeet/android/ParakeetApp/settings.gradle.kts +++ /dev/null @@ -1,24 +0,0 @@ -pluginManagement { - repositories { - google { - content { - includeGroupByRegex("com\\.android.*") - includeGroupByRegex("com\\.google.*") - includeGroupByRegex("androidx.*") - } - } - mavenCentral() - gradlePluginPortal() - } -} -dependencyResolutionManagement { - repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) - repositories { - google() - mavenCentral() - } -} - -rootProject.name = "Parakeet App" -include(":app") -includeBuild("../../../asr/android/shared") diff --git a/whisper/android/WhisperApp/.gitignore b/whisper/android/WhisperApp/.gitignore deleted file mode 100644 index 87f66f51..00000000 --- a/whisper/android/WhisperApp/.gitignore +++ /dev/null @@ -1,10 +0,0 @@ -*.iml -.gradle -/local.properties -.idea/ -.DS_Store -/build -/captures -.externalNativeBuild -.cxx -local.properties diff --git a/whisper/android/WhisperApp/README.md b/whisper/android/WhisperApp/README.md deleted file mode 100644 index 884d7af2..00000000 --- a/whisper/android/WhisperApp/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# Whisper Demo App - -This app demonstrates running the Whisper speech recognition model on Android using ExecuTorch. - -> **Note:** The ExecuTorch `AsrModule` API is not yet released. We will give a snapshot AAR soon™ - -## Export Model Files - -Export the audio preprocessor and model `.pte` files following the instructions at: -https://github.com/pytorch/executorch/tree/main/examples/models/whisper - -This app requires both a model `.pte` and a preprocessor `.pte` file. - -## Run the App - -1. Open WhisperApp in Android Studio -2. Copy the `executorch.aar` library (with audio JNI bindings) into `app/libs` -3. Build and run on device - -## Demo - -https://github.com/user-attachments/assets/eb4c4ae6-b89f-4eb4-a291-549a42c95f54 diff --git a/whisper/android/WhisperApp/app/.gitignore b/whisper/android/WhisperApp/app/.gitignore deleted file mode 100644 index 42afabfd..00000000 --- a/whisper/android/WhisperApp/app/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/whisper/android/WhisperApp/app/proguard-rules.pro b/whisper/android/WhisperApp/app/proguard-rules.pro deleted file mode 100644 index 481bb434..00000000 --- a/whisper/android/WhisperApp/app/proguard-rules.pro +++ /dev/null @@ -1,21 +0,0 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/whisper/android/WhisperApp/app/src/main/AndroidManifest.xml b/whisper/android/WhisperApp/app/src/main/AndroidManifest.xml deleted file mode 100644 index 32315663..00000000 --- a/whisper/android/WhisperApp/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - diff --git a/whisper/android/WhisperApp/app/src/main/res/drawable/ic_launcher_background.xml b/whisper/android/WhisperApp/app/src/main/res/drawable/ic_launcher_background.xml deleted file mode 100644 index 07d5da9c..00000000 --- a/whisper/android/WhisperApp/app/src/main/res/drawable/ic_launcher_background.xml +++ /dev/null @@ -1,170 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/whisper/android/WhisperApp/app/src/main/res/drawable/ic_launcher_foreground.xml b/whisper/android/WhisperApp/app/src/main/res/drawable/ic_launcher_foreground.xml deleted file mode 100644 index 2b068d11..00000000 --- a/whisper/android/WhisperApp/app/src/main/res/drawable/ic_launcher_foreground.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/whisper/android/WhisperApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/whisper/android/WhisperApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index 6f3b755b..00000000 --- a/whisper/android/WhisperApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/whisper/android/WhisperApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/whisper/android/WhisperApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml deleted file mode 100644 index 6f3b755b..00000000 --- a/whisper/android/WhisperApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/whisper/android/WhisperApp/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/whisper/android/WhisperApp/app/src/main/res/mipmap-hdpi/ic_launcher.webp deleted file mode 100644 index c209e78ecd372343283f4157dcfd918ec5165bb3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1404 zcmV-?1%vuhNk&F=1pok7MM6+kP&il$0000G0000-002h-06|PpNX!5L00Dqw+t%{r zzW2vH!KF=w&cMnnN@{whkTw+#mAh0SV?YL=)3MimFYCWp#fpdtz~8$hD5VPuQgtcN zXl<@<#Cme5f5yr2h%@8TWh?)bSK`O z^Z@d={gn7J{iyxL_y_%J|L>ep{dUxUP8a{byupH&!UNR*OutO~0{*T4q5R6@ApLF! z5{w?Z150gC7#>(VHFJZ-^6O@PYp{t!jH(_Z*nzTK4 zkc{fLE4Q3|mA2`CWQ3{8;gxGizgM!zccbdQoOLZc8hThi-IhN90RFT|zlxh3Ty&VG z?Fe{#9RrRnxzsu|Lg2ddugg7k%>0JeD+{XZ7>Z~{=|M+sh1MF7~ zz>To~`~LVQe1nNoR-gEzkpe{Ak^7{{ZBk2i_<+`Bq<^GB!RYG+z)h;Y3+<{zlMUYd zrd*W4w&jZ0%kBuDZ1EW&KLpyR7r2=}fF2%0VwHM4pUs}ZI2egi#DRMYZPek*^H9YK zay4Iy3WXFG(F14xYsoDA|KXgGc5%2DhmQ1gFCkrgHBm!lXG8I5h*uf{rn48Z!_@ z4Bk6TJAB2CKYqPjiX&mWoW>OPFGd$wqroa($ne7EUK;#3VYkXaew%Kh^3OrMhtjYN?XEoY`tRPQsAkH-DSL^QqyN0>^ zmC>{#F14jz4GeW{pJoRpLFa_*GI{?T93^rX7SPQgT@LbLqpNA}<@2wH;q493)G=1Y z#-sCiRNX~qf3KgiFzB3I>4Z%AfS(3$`-aMIBU+6?gbgDb!)L~A)je+;fR0jWLL-Fu z4)P{c7{B4Hp91&%??2$v9iRSFnuckHUm}or9seH6 z>%NbT+5*@L5(I9j@06@(!{ZI?U0=pKn8uwIg&L{JV14+8s2hnvbRrU|hZCd}IJu7*;;ECgO%8_*W Kmw_-CKmY()leWbG diff --git a/whisper/android/WhisperApp/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/whisper/android/WhisperApp/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp deleted file mode 100644 index b2dfe3d1ba5cf3ee31b3ecc1ced89044a1f3b7a9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2898 zcmV-Y3$650Nk&FW3jhFDMM6+kP&il$0000G0000-002h-06|PpNWB9900E$G+qN-D z+81ABX7q?;bwx%xBg?kcwr$(C-Tex-ZCkHUw(Y9#+`E5-zuONG5fgw~E2WDng@Bc@ z24xy+R1n%~6xI#u9vJ8zREI)sb<&Il(016}Z~V1n^PU3-_H17A*Bf^o)&{_uBv}Py zulRfeE8g(g6HFhk_?o_;0@tz?1I+l+Y#Q*;RVC?(ud`_cU-~n|AX-b`JHrOIqn(-t&rOg-o`#C zh0LPxmbOAEb;zHTu!R3LDh1QO zZTf-|lJNUxi-PpcbRjw3n~n-pG;$+dIF6eqM5+L();B2O2tQ~|p{PlpNcvDbd1l%c zLtXn%lu(3!aNK!V#+HNn_D3lp z2%l+hK-nsj|Bi9;V*WIcQRTt5j90A<=am+cc`J zTYIN|PsYAhJ|=&h*4wI4ebv-C=Be#u>}%m;a{IGmJDU`0snWS&$9zdrT(z8#{OZ_Y zxwJx!ZClUi%YJjD6Xz@OP8{ieyJB=tn?>zaI-4JN;rr`JQbb%y5h2O-?_V@7pG_+y z(lqAsqYr!NyVb0C^|uclHaeecG)Sz;WV?rtoqOdAAN{j%?Uo%owya(F&qps@Id|Of zo@~Y-(YmfB+chv^%*3g4k3R0WqvuYUIA+8^SGJ{2Bl$X&X&v02>+0$4?di(34{pt* zG=f#yMs@Y|b&=HyH3k4yP&goF2LJ#tBLJNNDo6lG06r}ghC-pC4Q*=x3;|+W04zte zAl>l4kzUBQFYF(E`KJy?ZXd1tnfbH+Z~SMmA21KokJNs#eqcXWKUIC>{TuoKe^vhF z);H)o`t9j~`$h1D`#bxe@E`oE`cM9w(@)5Bp8BNukIwM>wZHfd0S;5bcXA*5KT3bj zc&_~`&{z7u{Et!Z_k78H75gXf4g8<_ul!H$eVspPeU3j&&Au=2R*Zp#M9$9s;fqwgzfiX=E_?BwVcfx3tG9Q-+<5fw z%Hs64z)@Q*%s3_Xd5>S4dg$s>@rN^ixeVj*tqu3ZV)biDcFf&l?lGwsa zWj3rvK}?43c{IruV2L`hUU0t^MemAn3U~x3$4mFDxj=Byowu^Q+#wKRPrWywLjIAp z9*n}eQ9-gZmnd9Y0WHtwi2sn6n~?i#n9VN1B*074_VbZZ=WrpkMYr{RsI ztM_8X1)J*DZejxkjOTRJ&a*lrvMKBQURNP#K)a5wIitfu(CFYV4FT?LUB$jVwJSZz zNBFTWg->Yk0j&h3e*a5>B=-xM7dE`IuOQna!u$OoxLlE;WdrNlN)1 z7**de7-hZ!(%_ZllHBLg`Ir#|t>2$*xVOZ-ADZKTN?{(NUeLU9GbuG-+Axf*AZ-P1 z0ZZ*fx+ck4{XtFsbcc%GRStht@q!m*ImssGwuK+P@%gEK!f5dHymg<9nSCXsB6 zQ*{<`%^bxB($Z@5286^-A(tR;r+p7B%^%$N5h%lb*Vlz-?DL9x;!j<5>~kmXP$E}m zQV|7uv4SwFs0jUervsxVUm>&9Y3DBIzc1XW|CUZrUdb<&{@D5yuLe%Xniw^x&{A2s z0q1+owDSfc3Gs?ht;3jw49c#mmrViUfX-yvc_B*wY|Lo7; zGh!t2R#BHx{1wFXReX*~`NS-LpSX z#TV*miO^~B9PF%O0huw!1Zv>^d0G3$^8dsC6VI!$oKDKiXdJt{mGkyA`+Gwd4D-^1qtNTUK)`N*=NTG-6}=5k6suNfdLt*dt8D| z%H#$k)z#ZRcf|zDWB|pn<3+7Nz>?WW9WdkO5(a^m+D4WRJ9{wc>Y}IN)2Kbgn;_O? zGqdr&9~|$Y0tP=N(k7^Eu;iO*w+f%W`20BNo)=Xa@M_)+o$4LXJyiw{F?a633SC{B zl~9FH%?^Rm*LVz`lkULs)%idDX^O)SxQol(3jDRyBVR!7d`;ar+D7do)jQ}m`g$TevUD5@?*P8)voa?kEe@_hl{_h8j&5eB-5FrYW&*FHVt$ z$kRF9Nstj%KRzpjdd_9wO=4zO8ritN*NPk_9avYrsF(!4))tm{Ga#OY z(r{0buexOzu7+rw8E08Gxd`LTOID{*AC1m*6Nw@osfB%0oBF5sf<~wH1kL;sd zo)k6^VyRFU`)dt*iX^9&QtWbo6yE8XXH?`ztvpiOLgI3R+=MOBQ9=rMVgi<*CU%+d1PQQ0a1U=&b0vkF207%xU0ssI2 diff --git a/whisper/android/WhisperApp/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/whisper/android/WhisperApp/app/src/main/res/mipmap-mdpi/ic_launcher.webp deleted file mode 100644 index 4f0f1d64e58ba64d180ce43ee13bf9a17835fbca..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 982 zcmV;{11bDcNk&G_0{{S5MM6+kP&il$0000G0000l001ul06|PpNU8t;00Dqo+t#w^ z^1csucXz7-Qrhzl9HuHB%l>&>1tG2^vb*E&k^T3$FG1eQZ51g$uv4V+kI`0<^1Z@N zk?Jjh$olyC%l>)Xq;7!>{iBj&BjJ`P&$fsCfpve_epJOBkTF?nu-B7D!hO=2ZR}

C%4 zc_9eOXvPbC4kzU8YowIA8cW~Uv|eB&yYwAObSwL2vY~UYI7NXPvf3b+c^?wcs~_t{ ze_m66-0)^{JdOMKPwjpQ@Sna!*?$wTZ~su*tNv7o!gXT!GRgivP}ec?5>l1!7<(rT zds|8x(qGc673zrvYIz;J23FG{9nHMnAuP}NpAED^laz3mAN1sy+NXK)!6v1FxQ;lh zOBLA>$~P3r4b*NcqR;y6pwyhZ3_PiDb|%n1gGjl3ZU}ujInlP{eks-#oA6>rh&g+!f`hv#_%JrgYPu z(U^&XLW^QX7F9Z*SRPpQl{B%x)_AMp^}_v~?j7 zapvHMKxSf*Mtyx8I}-<*UGn3)oHd(nn=)BZ`d$lDBwq_GL($_TPaS{UeevT(AJ`p0 z9%+hQb6z)U9qjbuXjg|dExCLjpS8$VKQ55VsIC%@{N5t{NsW)=hNGI`J=x97_kbz@ E0Of=7!TQj4N+cqN`nQhxvX7dAV-`K|Ub$-q+H-5I?Tx0g9jWxd@A|?POE8`3b8fO$T))xP* z(X?&brZw({`)WU&rdAs1iTa0x6F@PIxJ&&L|dpySV!ID|iUhjCcKz(@mE z!x@~W#3H<)4Ae(4eQJRk`Iz3<1)6^m)0b_4_TRZ+cz#eD3f8V;2r-1fE!F}W zEi0MEkTTx}8i1{`l_6vo0(Vuh0HD$I4SjZ=?^?k82R51bC)2D_{y8mi_?X^=U?2|F{Vr7s!k(AZC$O#ZMyavHhlQ7 zUR~QXuH~#o#>(b$u4?s~HLF*3IcF7023AlwAYudn0FV~|odGH^05AYPEfR)8p`i{n zwg3zPVp{+wOsxKc>)(pMupKF!Y2HoUqQ3|Yu|8lwR=?5zZuhG6J?H`bSNk_wPoM{u zSL{c@pY7+c2kck>`^q1^^gR0QB7Y?KUD{vz-uVX~;V-rW)PDcI)$_UjgVV?S?=oLR zf4}zz{#*R_{LkiJ#0RdQLNC^2Vp%JPEUvG9ra2BVZ92(p9h7Ka@!yf9(lj#}>+|u* z;^_?KWdzkM`6gqPo9;;r6&JEa)}R3X{(CWv?NvgLeOTq$cZXqf7|sPImi-7cS8DCN zGf;DVt3Am`>hH3{4-WzH43Ftx)SofNe^-#|0HdCo<+8Qs!}TZP{HH8~z5n`ExcHuT zDL1m&|DVpIy=xsLO>8k92HcmfSKhflQ0H~9=^-{#!I1g(;+44xw~=* zxvNz35vfsQE)@)Zsp*6_GjYD};Squ83<_?^SbALb{a`j<0Gn%6JY!zhp=Fg}Ga2|8 z52e1WU%^L1}15Ex0fF$e@eCT(()_P zvV?CA%#Sy08_U6VPt4EtmVQraWJX` zh=N|WQ>LgrvF~R&qOfB$!%D3cGv?;Xh_z$z7k&s4N)$WYf*k=|*jCEkO19{h_(%W4 zPuOqbCw`SeAX*R}UUsbVsgtuG?xs(#Ikx9`JZoQFz0n*7ZG@Fv@kZk`gzO$HoA9kN z8U5{-yY zvV{`&WKU2$mZeoBmiJrEdzUZAv1sRxpePdg1)F*X^Y)zp^Y*R;;z~vOv-z&)&G)JQ{m!C9cmziu1^nHA z`#`0c>@PnQ9CJKgC5NjJD8HM3|KC(g5nnCq$n0Gsu_DXk36@ql%npEye|?%RmG)

FJ$wK}0tWNB{uH;AM~i diff --git a/whisper/android/WhisperApp/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/whisper/android/WhisperApp/app/src/main/res/mipmap-xhdpi/ic_launcher.webp deleted file mode 100644 index 948a3070fe34c611c42c0d3ad3013a0dce358be0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1900 zcmV-y2b1_xNk&Fw2LJ$9MM6+kP&il$0000G0001A003VA06|PpNH75a00DqwTbm-~ zullQTcXxO9ki!OCRx^i?oR|n!<8G0=kI^!JSjFi-LL*`V;ET0H2IXfU0*i>o6o6Gy zRq6Ap5(_{XLdXcL-MzlN`ugSdZY_`jXhcENAu)N_0?GhF))9R;E`!bo9p?g?SRgw_ zEXHhFG$0{qYOqhdX<(wE4N@es3VIo$%il%6xP9gjiBri+2pI6aY4 zJbgh-Ud|V%3O!IcHKQx1FQH(_*TK;1>FQWbt^$K1zNn^cczkBs=QHCYZ8b&l!UV{K z{L0$KCf_&KR^}&2Fe|L&?1I7~pBENnCtCuH3sjcx6$c zwqkNkru);ie``q+_QI;IYLD9OV0ZxkuyBz|5<$1BH|vtey$> z5oto4=l-R-Aaq`Dk0}o9N0VrkqW_#;!u{!bJLDq%0092{Ghe=F;(kn} z+sQ@1=UlX30+2nWjkL$B^b!H2^QYO@iFc0{(-~yXj2TWz?VG{v`Jg zg}WyYnwGgn>{HFaG7E~pt=)sOO}*yd(UU-D(E&x{xKEl6OcU?pl)K%#U$dn1mDF19 zSw@l8G!GNFB3c3VVK0?uyqN&utT-D5%NM4g-3@Sii9tSXKtwce~uF zS&Jn746EW^wV~8zdQ1XC28~kXu8+Yo9p!<8h&(Q({J*4DBglPdpe4M_mD8AguZFn~ ztiuO~{6Bx?SfO~_ZV(GIboeR9~hAym{{fV|VM=77MxDrbW6`ujX z<3HF(>Zr;#*uCvC*bpoSr~C$h?_%nXps@A)=l_;({Fo#6Y1+Zv`!T5HB+)#^-Ud_; zBwftPN=d8Vx)*O1Mj+0oO=mZ+NVH*ptNDC-&zZ7Hwho6UQ#l-yNvc0Cm+2$$6YUk2D2t#vdZX-u3>-Be1u9gtTBiMB^xwWQ_rgvGpZ6(C@e23c!^K=>ai-Rqu zhqT`ZQof;9Bu!AD(i^PCbYV%yha9zuoKMp`U^z;3!+&d@Hud&_iy!O-$b9ZLcSRh? z)R|826w}TU!J#X6P%@Zh=La$I6zXa#h!B;{qfug}O%z@K{EZECu6zl)7CiNi%xti0 zB{OKfAj83~iJvmpTU|&q1^?^cIMn2RQ?jeSB95l}{DrEPTW{_gmU_pqTc)h@4T>~& zluq3)GM=xa(#^VU5}@FNqpc$?#SbVsX!~RH*5p0p@w z;~v{QMX0^bFT1!cXGM8K9FP+=9~-d~#TK#ZE{4umGT=;dfvWi?rYj;^l_Zxywze`W z^Cr{55U@*BalS}K%Czii_80e0#0#Zkhlij4-~I@}`-JFJ7$5{>LnoJSs??J8kWVl6|8A}RCGAu9^rAsfCE=2}tHwl93t0C?#+jMpvr7O3`2=tr{Hg$=HlnjVG^ewm|Js0J*kfPa6*GhtB>`fN!m#9J(sU!?(OSfzY*zS(FJ<-Vb zfAIg+`U)YaXv#sY(c--|X zEB+TVyZ%Ie4L$gi#Fc++`h6%vzsS$pjz9aLt+ZL(g;n$Dzy5=m=_TV(3H8^C{r0xd zp#a%}ht55dOq?yhwYPrtp-m1xXp;4X;)NhxxUpgP%XTLmO zcjaFva^}dP3$&sfFTIR_jC=2pHh9kpI@2(6V*GQo7Ws)`j)hd+tr@P~gR*2gO@+1? zG<`_tB+LJuF|SZ9tIec;h%}}6WClT`L>HSW?E{Hp1h^+mlbf_$9zA>!ug>NALJsO{ mU%z=YwVD?}XMya)Bp;vlyE5&E_6!fzx9pwrdz474!~g(M6R?N? diff --git a/whisper/android/WhisperApp/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/whisper/android/WhisperApp/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp deleted file mode 100644 index 1b9a6956b3acdc11f40ce2bb3f6efbd845cc243f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3918 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�(p-{x$&=IACIfoAR!za)F-^da-t3#0Dycnp zwO~NVXwXCl;jE<}>%@xz|=8fIJAB?>+E{7)|4l${4ngA3G|=r z2Dyv;VVWSgZx9Wj>qUjleGl3Ei9K4>h!(lPS%8VOG>Xu0%6VDz^O=bjJmuP7>DeUv zrbI}MlHB^^d?{zv6d=@_ZD2lg1&G7UjnVN{1}9WkaM3H~btX0GtSzB+tZ^qRgWo4m z!GmimlG$=wgXCnr6j@m<1gAL46#T~5Bnm=2{^@>|t&`9mkEPddj zAvG~@Tv~TAm2i%VW}R-g(Z0)z-Y|szHr@rk>4MAyG*Ma*7Yh#H7(!-5>DZ@8r;_dx z{prSe<>~099F8vsYd2xff7uAS%7{S)f(|@me3t2$iy&NEc7OUEchp@9A|X;;IA>8!oX+y(BKJ$EzV* znR$z;!L$s7uy@{OT~nG#B!NRraT8(X##Ho!0r_o@gg0CA-9H^;-uE&?$2$nHv_00o z%cbuUc-tCx$Uh&EZ4Nf4Zgqv)Y6>usG3>GeQnxx_Z6+PcbX-+ysbt1hQ`K1LDpOE? zrAhIZhSN9yVIAOa22gn577tbc&i3|3V8NWy&!tw##`}9*x}gtI^h1DzZRA>UuaJG) zaZ7j)dq!O}{?#8Y7~7i6fHh4{`pL?>-18|p!S75Y#^DM>-S3)vuZG+Q7l@ek zQP~#cBpWgg#mApc_sPYjpw8odQuRokmTkzcNl`^CcKB7e&;zViV;{Y{o^Y$%7i0m# z62%#1Lq!RC?}lK>%mp}T!3Xv;L*0v*>USLm``N%>w>@fwC+#T&Tx2bN4w(20JB}oU zuSa6v^kXi0xPs?pbaOHnyiqq6By1EZY9OZ^^QA>{q-Hsd&m`pbQ%8121aWG-F5xf zlZ%;B{;C>X19|`^_?dVyCq>n+41w7|!tUS!{9rHlbhX=SZO5CQ^;!Du_E7*`GiR^Q w)2!4MKjfSAeNo!9>IaV6aUZ*?W>} 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

UmY2RIYtNPVDpE$%vda+HD#3m z&VuXJ{BK&Qe+rBa7eq}Q(bq|tn(RrJAk|ztj2(i{d>nmQnM?;HF2k&9sA6up5tmjl z7lySlzMbifH17-m-Lwa_F&e7nOH?ESi3#ckR3tsM+jsck3`oG!uMS}|eAwVXv>}qxwq?QY%QJ0}r@^;fhuUA9W z*BVl>TGo&N004@xSiwDUXUvp51sVmqO3m)=B55aPwf@0=e}cN+$-BdKxY`YrT_4)0 z_d10#i44Q*rFr8MC>*)v$EJvz``(pb{e&*6k+b zsMz%($|1+8hn8c2?P(l@;Rb&CsZeYoCI3?2!LqjbwPXW3z4G$Qfj=cT5Yb%vY0(AX oeb?AaKtwrnc|$|zzw9vfvn^aJJ!zd)XFXqqy0000001=f@-~a#s diff --git a/whisper/android/WhisperApp/app/src/main/res/values/strings.xml b/whisper/android/WhisperApp/app/src/main/res/values/strings.xml deleted file mode 100644 index 45afbdd0..00000000 --- a/whisper/android/WhisperApp/app/src/main/res/values/strings.xml +++ /dev/null @@ -1,3 +0,0 @@ - - Whisper App - \ No newline at end of file diff --git a/whisper/android/WhisperApp/build.gradle.kts b/whisper/android/WhisperApp/build.gradle.kts deleted file mode 100644 index 952b9306..00000000 --- a/whisper/android/WhisperApp/build.gradle.kts +++ /dev/null @@ -1,6 +0,0 @@ -// Top-level build file where you can add configuration options common to all sub-projects/modules. -plugins { - alias(libs.plugins.android.application) apply false - alias(libs.plugins.kotlin.android) apply false - alias(libs.plugins.kotlin.compose) apply false -} \ No newline at end of file diff --git a/whisper/android/WhisperApp/gradle.properties b/whisper/android/WhisperApp/gradle.properties deleted file mode 100644 index 20e2a015..00000000 --- a/whisper/android/WhisperApp/gradle.properties +++ /dev/null @@ -1,23 +0,0 @@ -# Project-wide Gradle settings. -# IDE (e.g. Android Studio) users: -# Gradle settings configured through the IDE *will override* -# any settings specified in this file. -# For more details on how to configure your build environment visit -# http://www.gradle.org/docs/current/userguide/build_environment.html -# Specifies the JVM arguments used for the daemon process. -# The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -# When configured, Gradle will run in incubating parallel mode. -# This option should only be used with decoupled projects. For more details, visit -# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects -# org.gradle.parallel=true -# AndroidX package structure to make it clearer which packages are bundled with the -# Android operating system, and which are packaged with your app's APK -# https://developer.android.com/topic/libraries/support-library/androidx-rn -android.useAndroidX=true -# Kotlin code style for this project: "official" or "obsolete": -kotlin.code.style=official -# Enables namespacing of each library's R class so that its R class includes only the -# resources declared in the library itself and none from the library's dependencies, -# thereby reducing the size of the R class for that library -android.nonTransitiveRClass=true \ No newline at end of file diff --git a/whisper/android/WhisperApp/gradle/wrapper/gradle-wrapper.jar b/whisper/android/WhisperApp/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index e708b1c023ec8b20f512888fe07c5bd3ff77bb8f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 59203 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

K7UpUK$>l@ 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 E2O(~oYXATM diff --git a/whisper/android/WhisperApp/gradle/wrapper/gradle-wrapper.properties b/whisper/android/WhisperApp/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index 3324d4d6..00000000 --- a/whisper/android/WhisperApp/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,6 +0,0 @@ -#Fri Aug 08 17:07:04 PDT 2025 -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists diff --git a/whisper/android/WhisperApp/gradlew b/whisper/android/WhisperApp/gradlew deleted file mode 100755 index 4f906e0c..00000000 --- a/whisper/android/WhisperApp/gradlew +++ /dev/null @@ -1,185 +0,0 @@ -#!/usr/bin/env sh - -# -# Copyright 2015 the original author or authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -############################################################################## -## -## Gradle start up script for UN*X -## -############################################################################## - -# Attempt to set APP_HOME -# Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi -done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null - -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" - -warn () { - echo "$*" -} - -die () { - echo - echo "$*" - echo - exit 1 -} - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; -esac - -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar - - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" - else - JAVACMD="$JAVA_HOME/bin/java" - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." -fi - -# Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi -fi - -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi - -# For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi - # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" - fi - i=`expr $i + 1` - done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac -fi - -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` - -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" - -exec "$JAVACMD" "$@" diff --git a/whisper/android/WhisperApp/gradlew.bat b/whisper/android/WhisperApp/gradlew.bat deleted file mode 100644 index ac1b06f9..00000000 --- a/whisper/android/WhisperApp/gradlew.bat +++ /dev/null @@ -1,89 +0,0 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem - -@if "%DEBUG%" == "" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega diff --git a/whisper/android/WhisperApp/settings.gradle.kts b/whisper/android/WhisperApp/settings.gradle.kts deleted file mode 100644 index dc9fa53e..00000000 --- a/whisper/android/WhisperApp/settings.gradle.kts +++ /dev/null @@ -1,25 +0,0 @@ -pluginManagement { - repositories { - google { - content { - includeGroupByRegex("com\\.android.*") - includeGroupByRegex("com\\.google.*") - includeGroupByRegex("androidx.*") - } - } - mavenCentral() - gradlePluginPortal() - } -} -dependencyResolutionManagement { - repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) - repositories { - google() - mavenCentral() - } -} - -rootProject.name = "Whisper App" -include(":app") -includeBuild("../../../asr/android/shared") - \ No newline at end of file From 07859ea4bb87c576d964990fd09efc51428d31b4 Mon Sep 17 00:00:00 2001 From: Hansong Zhang Date: Fri, 20 Feb 2026 16:13:33 -0800 Subject: [PATCH 3/3] Add README for unified ASR app --- asr/android/AsrApp/README.md | 37 ++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 asr/android/AsrApp/README.md diff --git a/asr/android/AsrApp/README.md b/asr/android/AsrApp/README.md new file mode 100644 index 00000000..8a54b7f2 --- /dev/null +++ b/asr/android/AsrApp/README.md @@ -0,0 +1,37 @@ +# ASR Demo App + +This app demonstrates running speech recognition models on Android using ExecuTorch. It supports both **Whisper** and **Parakeet** model families. + +## Supported Models + +| Model | Type | Details | +|-------|------|---------| +| Whisper Tiny/Small/Medium (INT8/INT4) | Streaming | Requires model, tokenizer, and preprocessor | +| Whisper Tiny/Small/Medium (FP32) | Streaming | Requires model, tokenizer, and preprocessor | +| Parakeet TDT 0.6B (INT4) | Synchronous | Requires model and tokenizer | + +## Export Model Files + +- **Whisper**: Follow the instructions at https://github.com/pytorch/executorch/tree/main/examples/models/whisper +- **Parakeet**: Follow the instructions at https://github.com/pytorch/executorch/tree/main/examples/models/parakeet + +## Run the App + +1. Open AsrApp in Android Studio +2. Copy the `executorch.aar` library (with ASR and Parakeet JNI bindings) into `app/libs/` +3. Build and run on device + +## Download Models + +The app includes a built-in download screen to fetch models from HuggingFace. Alternatively, push files manually: + +```bash +adb push model.pte /data/local/tmp/asr/ +adb push tokenizer.json /data/local/tmp/asr/ +adb push whisper_preprocessor.pte /data/local/tmp/asr/ # Whisper only +``` + +## Recording Behavior + +- **Whisper**: Click to start recording; automatically stops after 30 seconds +- **Parakeet**: Click to start recording; click again to stop (no time limit)