diff --git a/.github/workflows/java-codestyle.yml b/.github/workflows/java-codestyle.yml new file mode 100644 index 0000000..349e0b9 --- /dev/null +++ b/.github/workflows/java-codestyle.yml @@ -0,0 +1,30 @@ +name: Java CodeStyle + +on: + workflow_dispatch: + push: + branches: [ master ] + paths: + - '**/*.java' + - 'res/.lint/java/**' + pull_request: + branches: [ master, 'v[0-9]+.[0-9]+' ] + paths: + - '**/*.java' + - 'res/.lint/java/**' + +jobs: + check-java-codestyle: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Set up JDK 11 + uses: actions/setup-java@v3 + with: + java-version: '11' + distribution: 'temurin' + + - name: Check Java CodeStyle + run: java -Dconfig_loc=res/.lint/java/ -jar res/.lint/java/checkstyle-10.5.0-all.jar -c res/.lint/java/checkstyle.xml recipes/llm-voice-assistant/android diff --git a/recipes/llm-voice-assistant/README.md b/recipes/llm-voice-assistant/README.md index c8786ad..1f44579 100644 --- a/recipes/llm-voice-assistant/README.md +++ b/recipes/llm-voice-assistant/README.md @@ -12,5 +12,6 @@ Hands-free voice assistant powered by a large language model (LLM), all voice re ## Implementations - [Python](python) -- [Web](web) +- [Android](android) - [iOS](ios) +- [Web](web) diff --git a/recipes/llm-voice-assistant/android/.gitignore b/recipes/llm-voice-assistant/android/.gitignore new file mode 100644 index 0000000..9b41607 --- /dev/null +++ b/recipes/llm-voice-assistant/android/.gitignore @@ -0,0 +1,10 @@ +*.iml +.gradle +/local.properties +/.idea/ +.DS_Store +/build +/captures +.externalNativeBuild +release +test_resources \ No newline at end of file diff --git a/recipes/llm-voice-assistant/android/README.md b/recipes/llm-voice-assistant/android/README.md new file mode 100644 index 0000000..7b003b8 --- /dev/null +++ b/recipes/llm-voice-assistant/android/README.md @@ -0,0 +1,69 @@ +## See It In Action! + +[![LLM VA in Action](https://img.youtube.com/vi/5JkDVbkedBU/0.jpg)](https://www.youtube.com/watch?v=5JkDVbkedBU) + +## Compatibility + +- Android 5.0 (SDK 21+) + +## AccessKey + +AccessKey is your authentication and authorization token for deploying Picovoice SDKs, including picoLLM. Anyone who is +using Picovoice needs to have a valid AccessKey. You must keep your AccessKey secret. You would need internet +connectivity to validate your AccessKey with Picovoice license servers even though the LLM inference is running 100% +offline and completely free for open-weight models. Everyone who signs up for +[Picovoice Console](https://console.picovoice.ai/) receives a unique AccessKey. + +## picoLLM Model + +picoLLM Inference Engine supports a variety of open-weight models. The models can be downloaded from the [Picovoice Console](https://console.picovoice.ai/). + +Download your desired model file (`.pllm`) from the Picovoice Console. If you do not download the +file directly from your Android device, you will need to upload it to the device. +To upload the model to the device, use the Android Studio Device Explorer or `adb push`: +```console +adb push ~/model.pllm /sdcard/Downloads/ +``` + +## Usage + +1. Open the `LLMVoiceAssistant` project in Android Studio. +2. Copy your `AccessKey` from Picovoice Console into the `ACCESS_KEY` variable in [MainActivity.java](llm-voice-assistant/src/main/java/ai/picovoice/llmvoiceassistant/MainActivity.java). +3. Connect a device or launch an Android simulator. +4. Build and run the demo. +5. Press the `Load Model` button and select the model file (`.pllm`) from your device's storage. +6. Say "Picovoice", then you'll be able to prompt the voice assistant. + +## Custom Wake Word + +The demo's default wake phrase is `Picovoice`. You can generate your custom (branded) wake word using +Picovoice Console by following [Porcupine Wake Word documentation (https://picovoice.ai/docs/porcupine/). +Once you have the model trained, add it to your project by following these steps: + +1. Download the custom wake word file (`.ppn`) +2. Add it to the `${ANDROID_APP}/src/main/assets` directory of your Android project +3. Create an instance of Porcupine using the .setKeywordPaths builder method and the keyword path (relative to the assets directory or absolute path to the file on device): + +```java +porcupine = new Porcupine.Builder() + .setAccessKey("${ACCESS_KEY}") + .setKeywordPath("${KEYWORD_FILE_PATH}") + .build(getApplicationContext()); +``` + +## Profiling + +Profiling data is automatically printed in the app's `logcat`. + +### Real-time Factor (RTF) + +RTF is a standard metric for measuring the speed of speech processing (e.g., wake word, speech-to-text, and +text-to-speech). RTF is the CPU time divided by the processed (recognized or synthesized) audio length. +Hence, a lower RTF means a more efficient engine. + +### Token per Second (TPS) + +Token per second is the standard metric for measuring the speed of LLM inference engines. TPS is the number of +generated tokens divided by the compute time used to create them. A higher TPS is better. + + diff --git a/recipes/llm-voice-assistant/android/build.gradle b/recipes/llm-voice-assistant/android/build.gradle new file mode 100644 index 0000000..56f710b --- /dev/null +++ b/recipes/llm-voice-assistant/android/build.gradle @@ -0,0 +1,24 @@ +ext { + defaultTargetSdkVersion = 33 +} + +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:7.4.2' + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +tasks.register('clean', Delete) { + delete rootProject.buildDir +} diff --git a/recipes/llm-voice-assistant/android/gradle.properties b/recipes/llm-voice-assistant/android/gradle.properties new file mode 100644 index 0000000..c59e2b8 --- /dev/null +++ b/recipes/llm-voice-assistant/android/gradle.properties @@ -0,0 +1,17 @@ +# 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=-Xmx4096m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec: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 diff --git a/recipes/llm-voice-assistant/android/gradle/wrapper/gradle-wrapper.jar b/recipes/llm-voice-assistant/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..f6b961f Binary files /dev/null and b/recipes/llm-voice-assistant/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/recipes/llm-voice-assistant/android/gradle/wrapper/gradle-wrapper.properties b/recipes/llm-voice-assistant/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..3fadc5c --- /dev/null +++ b/recipes/llm-voice-assistant/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Tue Jun 29 23:02:09 PDT 2021 +distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/recipes/llm-voice-assistant/android/gradlew b/recipes/llm-voice-assistant/android/gradlew new file mode 100755 index 0000000..cccdd3d --- /dev/null +++ b/recipes/llm-voice-assistant/android/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## 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="" + +# 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, switch paths to Windows format before running java +if $cygwin ; 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=$((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" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/recipes/llm-voice-assistant/android/gradlew.bat b/recipes/llm-voice-assistant/android/gradlew.bat new file mode 100644 index 0000000..e95643d --- /dev/null +++ b/recipes/llm-voice-assistant/android/gradlew.bat @@ -0,0 +1,84 @@ +@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 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= + +@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 init + +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 init + +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 + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +: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 %CMD_LINE_ARGS% + +: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/recipes/llm-voice-assistant/android/llm-voice-assistant/.gitignore b/recipes/llm-voice-assistant/android/llm-voice-assistant/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/recipes/llm-voice-assistant/android/llm-voice-assistant/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/recipes/llm-voice-assistant/android/llm-voice-assistant/build.gradle b/recipes/llm-voice-assistant/android/llm-voice-assistant/build.gradle new file mode 100644 index 0000000..bd9995e --- /dev/null +++ b/recipes/llm-voice-assistant/android/llm-voice-assistant/build.gradle @@ -0,0 +1,70 @@ +apply plugin: 'com.android.application' + +Properties properties = new Properties() +if (rootProject.file("local.properties").exists()) { + properties.load(rootProject.file("local.properties").newDataInputStream()) + + if (project.hasProperty("storePassword")) { + properties.put("storePassword", project.getProperty("storePassword")) + } + if (project.hasProperty("storeFile")) { + properties.put("storeFile", project.getProperty("storeFile")) + } + if (project.hasProperty("keyAlias")) { + properties.put("keyAlias", project.getProperty("keyAlias")) + } + if (project.hasProperty("keyPassword")) { + properties.put("keyPassword", project.getProperty("keyPassword")) + } +} + +android { + compileSdk defaultTargetSdkVersion + defaultConfig { + applicationId "ai.picovoice.llmvoiceassistant" + minSdkVersion 21 + targetSdkVersion defaultTargetSdkVersion + versionCode 1 + versionName "1.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + signingConfigs { + release { + storePassword properties.getProperty("storePassword") + storeFile file(properties.getProperty("storeFile", ".dummy.jks")) + keyAlias properties.getProperty("keyAlias") + keyPassword properties.getProperty("keyPassword") + } + } + buildTypes { + debug { + signingConfig signingConfigs.release + } + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + signingConfig signingConfigs.release + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + lint { + abortOnError false + } + namespace 'ai.picovoice.llmvoiceassistant' +} + +dependencies { + implementation 'androidx.appcompat:appcompat:1.3.1' + implementation 'com.google.android.material:material:1.4.0' + implementation 'androidx.constraintlayout:constraintlayout:2.1.1' + + implementation 'ai.picovoice:android-voice-processor:1.0.2' + implementation 'ai.picovoice:porcupine-android:3.0.1' + implementation 'ai.picovoice:cheetah-android:2.0.0' + implementation 'ai.picovoice:picollm-android:1.0.0' + implementation 'ai.picovoice:orca-android:0.2.1' +} diff --git a/recipes/llm-voice-assistant/android/llm-voice-assistant/proguard-rules.pro b/recipes/llm-voice-assistant/android/llm-voice-assistant/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/recipes/llm-voice-assistant/android/llm-voice-assistant/proguard-rules.pro @@ -0,0 +1,21 @@ +# 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/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/AndroidManifest.xml b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/AndroidManifest.xml new file mode 100644 index 0000000..dd68075 --- /dev/null +++ b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/AndroidManifest.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + diff --git a/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/assets/cheetah_params.pv b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/assets/cheetah_params.pv new file mode 100644 index 0000000..15e2bd9 Binary files /dev/null and b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/assets/cheetah_params.pv differ diff --git a/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/assets/orca_params_female.pv b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/assets/orca_params_female.pv new file mode 100644 index 0000000..674f9f5 Binary files /dev/null and b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/assets/orca_params_female.pv differ diff --git a/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/ic_launcher-playstore.png b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..0e7d9f6 Binary files /dev/null and b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/ic_launcher-playstore.png differ diff --git a/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/java/ai/picovoice/llmvoiceassistant/MainActivity.java b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/java/ai/picovoice/llmvoiceassistant/MainActivity.java new file mode 100644 index 0000000..3616c90 --- /dev/null +++ b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/java/ai/picovoice/llmvoiceassistant/MainActivity.java @@ -0,0 +1,735 @@ +/* + Copyright 2024 Picovoice Inc. + + You may not use this file except in compliance with the license. A copy of the license is + located in the "LICENSE" file accompanying this source. + + 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. +*/ + +package ai.picovoice.llmvoiceassistant; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.content.pm.PackageManager; +import android.media.AudioFormat; +import android.media.AudioManager; +import android.media.AudioTrack; +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.text.Spannable; +import android.text.SpannableStringBuilder; +import android.text.style.ForegroundColorSpan; +import android.util.Log; +import android.view.View; +import android.widget.Button; +import android.widget.ImageButton; +import android.widget.ProgressBar; +import android.widget.ScrollView; +import android.widget.TextView; + +import androidx.activity.result.ActivityResultCallback; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.constraintlayout.widget.ConstraintLayout; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; +import androidx.core.content.res.ResourcesCompat; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Arrays; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; + +import ai.picovoice.android.voiceprocessor.VoiceProcessor; +import ai.picovoice.android.voiceprocessor.VoiceProcessorException; +import ai.picovoice.cheetah.Cheetah; +import ai.picovoice.cheetah.CheetahException; +import ai.picovoice.cheetah.CheetahTranscript; +import ai.picovoice.orca.Orca; +import ai.picovoice.orca.OrcaException; +import ai.picovoice.orca.OrcaSynthesizeParams; +import ai.picovoice.picollm.PicoLLM; +import ai.picovoice.picollm.PicoLLMCompletion; +import ai.picovoice.picollm.PicoLLMDialog; +import ai.picovoice.picollm.PicoLLMException; +import ai.picovoice.picollm.PicoLLMGenerateParams; +import ai.picovoice.porcupine.Porcupine; +import ai.picovoice.porcupine.PorcupineException; + +public class MainActivity extends AppCompatActivity { + + private enum UIState { + INIT, + LOADING_MODEL, + WAKE_WORD, + STT, + LLM_TTS + } + + private static final String ACCESS_KEY = "${YOUR_ACCESS_KEY_HERE}"; + + private static final String STT_MODEL_FILE = "cheetah_params.pv"; + + private static final String TTS_MODEL_FILE = "orca_params_female.pv"; + + private static final int COMPLETION_TOKEN_LIMIT = 128; + + private static final int TTS_WARMUP_SECONDS = 1; + + private static final String[] STOP_PHRASES = new String[]{ + "", // Llama-2, Mistral, and Mixtral + "", // Gemma + "<|endoftext|>", // Phi-2 + "<|eot_id|>", // Llama-3 + }; + + private final VoiceProcessor voiceProcessor = VoiceProcessor.getInstance(); + + private Porcupine porcupine; + private Cheetah cheetah; + private PicoLLM picollm; + private Orca orca; + + private PicoLLMDialog dialog; + + private PicoLLMCompletion finalCompletion; + + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + + private final ExecutorService engineExecutor = Executors.newSingleThreadExecutor(); + private final ExecutorService ttsSynthesizeExecutor = Executors.newSingleThreadExecutor(); + private final ExecutorService ttsPlaybackExecutor = Executors.newSingleThreadExecutor(); + + private UIState currentState = UIState.INIT; + + private StringBuilder llmPromptText = new StringBuilder(); + + private ConstraintLayout loadModelLayout; + private ConstraintLayout chatLayout; + + private Button loadModelButton; + private TextView loadModelText; + private ProgressBar loadModelProgress; + + private TextView chatText; + + private ScrollView chatTextScrollView; + + private TextView statusText; + + private ProgressBar statusProgress; + + private ImageButton loadNewModelButton; + + private ImageButton clearTextButton; + + private SpannableStringBuilder chatTextBuilder; + + private int spanColour; + + @SuppressLint({"DefaultLocale", "SetTextI18n"}) + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.main_layout); + + loadModelLayout = findViewById(R.id.loadModelLayout); + chatLayout = findViewById(R.id.chatLayout); + + loadModelText = findViewById(R.id.loadModelText); + loadModelProgress = findViewById(R.id.loadModelProgress); + loadModelButton = findViewById(R.id.loadModelButton); + + loadModelButton.setOnClickListener(view -> { + modelSelection.launch(new String[]{"application/octet-stream"}); + }); + + spanColour = ContextCompat.getColor(this, R.color.colorPrimary); + + updateUIState(UIState.INIT); + + chatText = findViewById(R.id.chatText); + chatTextScrollView = findViewById(R.id.chatScrollView); + statusText = findViewById(R.id.statusText); + statusProgress = findViewById(R.id.statusProgress); + + loadNewModelButton = findViewById(R.id.loadNewModelButton); + loadNewModelButton.setOnClickListener(view -> { + if (picollm != null) { + picollm.delete(); + picollm = null; + } + updateUIState(UIState.INIT); + mainHandler.post(() -> chatText.setText("")); + }); + + clearTextButton = findViewById(R.id.clearButton); + clearTextButton.setOnClickListener(view -> { + chatTextBuilder = new SpannableStringBuilder(); + mainHandler.post(() -> { + chatText.setText(""); + clearTextButton.setEnabled(false); + clearTextButton.setImageDrawable( + ResourcesCompat.getDrawable(getResources(), + R.drawable.clear_button_disabled, + null)); + }); + + try { + dialog = picollm.getDialogBuilder().build(); + } catch (PicoLLMException e) { + updateUIState(UIState.WAKE_WORD); + mainHandler.post(() -> chatText.setText(e.toString())); + } + }); + } + + ActivityResultLauncher modelSelection = registerForActivityResult( + new ActivityResultContracts.OpenDocument(), + new ActivityResultCallback() { + @SuppressLint("SetTextI18n") + @Override + public void onActivityResult(Uri selectedUri) { + updateUIState(UIState.LOADING_MODEL); + + if (selectedUri == null) { + updateUIState(UIState.INIT); + mainHandler.post(() -> loadModelText.setText("No file selected")); + return; + } + + engineExecutor.submit(() -> { + File llmModelFile = extractModelFile(selectedUri); + if (llmModelFile == null || !llmModelFile.exists()) { + updateUIState(UIState.INIT); + mainHandler.post(() -> loadModelText.setText("Unable to access selected file")); + return; + } + + initEngines(llmModelFile); + }); + } + }); + + private void initEngines(File modelFile) { + mainHandler.post(() -> loadModelText.setText("Loading Porcupine...")); + try { + porcupine = new Porcupine.Builder() + .setAccessKey(ACCESS_KEY) + .setKeyword(Porcupine.BuiltInKeyword.PICOVOICE) + .build(getApplicationContext()); + } catch (PorcupineException e) { + onEngineInitError(e.getMessage()); + return; + } + + mainHandler.post(() -> loadModelText.setText("Loading Cheetah...")); + try { + cheetah = new Cheetah.Builder() + .setAccessKey(ACCESS_KEY) + .setModelPath(STT_MODEL_FILE) + .setEnableAutomaticPunctuation(true) + .build(getApplicationContext()); + } catch (CheetahException e) { + onEngineInitError(e.getMessage()); + return; + } + + mainHandler.post(() -> loadModelText.setText("Loading picoLLM...")); + try { + picollm = new PicoLLM.Builder() + .setAccessKey(ACCESS_KEY) + .setModelPath(modelFile.getAbsolutePath()) + .build(); + dialog = picollm.getDialogBuilder().build(); + } catch (PicoLLMException e) { + onEngineInitError(e.getMessage()); + return; + } + + mainHandler.post(() -> loadModelText.setText("Loading Orca...")); + try { + orca = new Orca.Builder() + .setAccessKey(ACCESS_KEY) + .setModelPath(TTS_MODEL_FILE) + .build(getApplicationContext()); + } catch (OrcaException e) { + onEngineInitError(e.getMessage()); + return; + } + + chatTextBuilder = new SpannableStringBuilder(); + updateUIState(UIState.WAKE_WORD); + + voiceProcessor.addFrameListener(this::runWakeWordSTT); + + voiceProcessor.addErrorListener(error -> { + onEngineProcessError(error.getMessage()); + }); + + startWakeWordListening(); + } + + private void runWakeWordSTT(short[] frame) { + if (currentState == UIState.WAKE_WORD) { + try { + int keywordIndex = porcupine.process(frame); + if (keywordIndex == 0) { + llmPromptText = new StringBuilder(); + updateUIState(UIState.STT); + } + } catch (PorcupineException e) { + onEngineProcessError(e.getMessage()); + } + } else if (currentState == UIState.STT) { + try { + CheetahTranscript result = cheetah.process(frame); + llmPromptText.append(result.getTranscript()); + mainHandler.post(() -> { + chatTextBuilder.append(result.getTranscript()); + chatText.setText(chatTextBuilder); + chatTextScrollView.fullScroll(ScrollView.FOCUS_DOWN); + }); + + if (result.getIsEndpoint()) { + CheetahTranscript finalResult = cheetah.flush(); + llmPromptText.append(finalResult.getTranscript()); + mainHandler.post(() -> { + chatTextBuilder.append( + String.format("%s\n\n", finalResult.getTranscript()) + ); + chatText.setText(chatTextBuilder); + chatTextScrollView.fullScroll(ScrollView.FOCUS_DOWN); + }); + + voiceProcessor.stop(); + + runLLM(llmPromptText.toString()); + } + } catch (CheetahException | VoiceProcessorException e) { + onEngineProcessError(e.getMessage()); + } + } + } + + private void runLLM(String prompt) { + if (prompt.length() == 0) { + return; + } + + AtomicBoolean isQueueingTokens = new AtomicBoolean(false); + CountDownLatch tokensReadyLatch = new CountDownLatch(1); + ConcurrentLinkedQueue tokenQueue = new ConcurrentLinkedQueue<>(); + + AtomicBoolean isQueueingPcm = new AtomicBoolean(false); + CountDownLatch pcmReadyLatch = new CountDownLatch(1); + ConcurrentLinkedQueue pcmQueue = new ConcurrentLinkedQueue<>(); + + updateUIState(UIState.LLM_TTS); + + finalCompletion = null; + + mainHandler.post(() -> { + int start = chatTextBuilder.length(); + chatTextBuilder.append("picoLLM:\n\n"); + chatTextBuilder.setSpan( + new ForegroundColorSpan(spanColour), + start, + start + 8, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + chatText.setText(chatTextBuilder); + }); + + engineExecutor.submit(() -> { + TPSProfiler picoLLMProfiler = new TPSProfiler(); + try { + isQueueingTokens.set(true); + + dialog.addHumanRequest(prompt); + finalCompletion = picollm.generate( + dialog.getPrompt(), + new PicoLLMGenerateParams.Builder() + .setStreamCallback(token -> { + picoLLMProfiler.tock(); + if (token != null && token.length() > 0) { + boolean containsStopPhrase = false; + for (String k : STOP_PHRASES) { + if (token.contains(k)) { + containsStopPhrase = true; + break; + } + } + + if (!containsStopPhrase) { + tokenQueue.add(token); + tokensReadyLatch.countDown(); + + mainHandler.post(() -> { + chatTextBuilder.append(token); + chatText.setText(chatTextBuilder); + chatTextScrollView.fullScroll(ScrollView.FOCUS_DOWN); + }); + } + } + }) + .setCompletionTokenLimit(COMPLETION_TOKEN_LIMIT) + .setStopPhrases(STOP_PHRASES) + .build()); + dialog.addLLMResponse(finalCompletion.getCompletion()); + Log.i("PICOVOICE", String.format("TPS: %.2f", picoLLMProfiler.tps())); + + isQueueingTokens.set(false); + + updateUIState(UIState.WAKE_WORD); + mainHandler.post(() -> { + clearTextButton.setEnabled(true); + clearTextButton.setImageDrawable( + ResourcesCompat.getDrawable(getResources(), + R.drawable.clear_button, + null)); + chatTextBuilder.append("\n\n"); + }); + + startWakeWordListening(); + } catch (PicoLLMException e) { + onEngineProcessError(e.getMessage()); + } + }); + + ttsSynthesizeExecutor.submit(() -> { + Orca.OrcaStream orcaStream; + try { + orcaStream = orca.streamOpen(new OrcaSynthesizeParams.Builder().build()); + } catch (OrcaException e) { + onEngineProcessError(e.getMessage()); + return; + } + + RTFProfiler orcaProfiler = new RTFProfiler(orca.getSampleRate()); + + short[] warmupPcm; + if (TTS_WARMUP_SECONDS > 0) { + warmupPcm = new short[0]; + } + + try { + tokensReadyLatch.await(); + } catch (InterruptedException e) { + onEngineProcessError(e.getMessage()); + return; + } + + isQueueingPcm.set(true); + while (isQueueingTokens.get() || !tokenQueue.isEmpty()) { + String token = tokenQueue.poll(); + if (token != null && token.length() > 0) { + try { + orcaProfiler.tick(); + short[] pcm = orcaStream.synthesize(token); + orcaProfiler.tock(pcm); + + if (pcm != null && pcm.length > 0) { + if (warmupPcm != null) { + int offset = warmupPcm.length; + warmupPcm = Arrays.copyOf(warmupPcm, offset + pcm.length); + System.arraycopy(pcm, 0, warmupPcm, offset, pcm.length); + if (warmupPcm.length > TTS_WARMUP_SECONDS * orca.getSampleRate()) { + pcmQueue.add(warmupPcm); + pcmReadyLatch.countDown(); + warmupPcm = null; + } + } else { + pcmQueue.add(pcm); + pcmReadyLatch.countDown(); + } + } + } catch (OrcaException e) { + onEngineProcessError(e.getMessage()); + return; + } + } + } + + try { + orcaProfiler.tick(); + short[] flushedPcm = orcaStream.flush(); + orcaProfiler.tock(flushedPcm); + + if (flushedPcm != null && flushedPcm.length > 0) { + if (warmupPcm != null) { + int offset = warmupPcm.length; + warmupPcm = Arrays.copyOf(warmupPcm, offset + flushedPcm.length); + System.arraycopy(flushedPcm, 0, warmupPcm, offset, flushedPcm.length); + pcmQueue.add(warmupPcm); + pcmReadyLatch.countDown(); + } + else { + pcmQueue.add(flushedPcm); + pcmReadyLatch.countDown(); + } + } + Log.i("PICOVOICE", String.format("RTF: %.2f", orcaProfiler.rtf())); + } catch (OrcaException e) { + onEngineProcessError(e.getMessage()); + } + + isQueueingPcm.set(false); + + orcaStream.close(); + }); + + ttsPlaybackExecutor.submit(() -> { + AudioTrack ttsOutput; + try { + ttsOutput = new AudioTrack( + AudioManager.STREAM_MUSIC, + orca.getSampleRate(), + AudioFormat.CHANNEL_OUT_MONO, + AudioFormat.ENCODING_PCM_16BIT, + AudioTrack.getMinBufferSize( + orca.getSampleRate(), + AudioFormat.CHANNEL_OUT_MONO, + AudioFormat.ENCODING_PCM_16BIT), + AudioTrack.MODE_STREAM); + + ttsOutput.play(); + } catch (Exception e) { + onEngineProcessError(e.getMessage()); + return; + } + + try { + pcmReadyLatch.await(); + } catch (InterruptedException e) { + onEngineProcessError(e.getMessage()); + return; + } + + while (isQueueingPcm.get() || !pcmQueue.isEmpty()) { + short[] pcm = pcmQueue.poll(); + if (pcm != null && pcm.length > 0) { + ttsOutput.write(pcm, 0, pcm.length); + } + } + + ttsOutput.stop(); + ttsOutput.release(); + }); + } + + private File extractModelFile(Uri uri) { + File modelFile = new File(getApplicationContext().getFilesDir(), "model.pllm"); + + try (InputStream is = getContentResolver().openInputStream(uri); + OutputStream os = new FileOutputStream(modelFile)) { + byte[] buffer = new byte[8192]; + int numBytesRead; + while ((numBytesRead = is.read(buffer)) != -1) { + os.write(buffer, 0, numBytesRead); + } + } catch (IOException e) { + return null; + } + + return modelFile; + } + + private void onEngineInitError(String message) { + updateUIState(UIState.INIT); + mainHandler.post(() -> loadModelText.setText(message)); + } + + private void onEngineProcessError(String message) { + updateUIState(UIState.WAKE_WORD); + mainHandler.post(() -> chatText.setText(message)); + } + + + private void startWakeWordListening() { + if (voiceProcessor.hasRecordAudioPermission(this)) { + try { + voiceProcessor.start(cheetah.getFrameLength(), cheetah.getSampleRate()); + } catch (VoiceProcessorException e) { + onEngineProcessError(e.getMessage()); + } + } else { + requestRecordPermission(); + } + } + + private void requestRecordPermission() { + ActivityCompat.requestPermissions( + this, + new String[]{Manifest.permission.RECORD_AUDIO}, + 0); + } + + @Override + public void onRequestPermissionsResult( + int requestCode, + @NonNull String[] permissions, + @NonNull int[] grantResults + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + if (grantResults.length == 0 || grantResults[0] == PackageManager.PERMISSION_DENIED) { + onEngineProcessError("Recording permission not granted"); + } else { + startWakeWordListening(); + } + } + + private void updateUIState(UIState state) { + mainHandler.post(() -> { + switch (state) { + case INIT: + loadModelLayout.setVisibility(View.VISIBLE); + chatLayout.setVisibility(View.INVISIBLE); + loadModelButton.setEnabled(true); + loadModelButton.setBackground( + ResourcesCompat.getDrawable( + getResources(), + R.drawable.button_background, + null)); + loadModelProgress.setVisibility(View.INVISIBLE); + loadModelText.setText(getResources().getString(R.string.intro_text)); + break; + case LOADING_MODEL: + loadModelLayout.setVisibility(View.VISIBLE); + chatLayout.setVisibility(View.INVISIBLE); + loadModelButton.setEnabled(false); + loadModelButton.setBackground( + ResourcesCompat.getDrawable( + getResources(), + R.drawable.button_disabled, + null)); + loadModelProgress.setVisibility(View.VISIBLE); + loadModelText.setText("Loading model..."); + break; + case WAKE_WORD: + loadModelLayout.setVisibility(View.INVISIBLE); + chatLayout.setVisibility(View.VISIBLE); + + loadNewModelButton.setImageDrawable( + ResourcesCompat.getDrawable( + getResources(), + R.drawable.arrow_back_button, + null)); + loadNewModelButton.setEnabled(true); + statusProgress.setVisibility(View.GONE); + statusText.setVisibility(View.VISIBLE); + statusText.setText("Say 'Picovoice'!"); + clearTextButton.setEnabled(false); + clearTextButton.setImageDrawable( + ResourcesCompat.getDrawable( + getResources(), + R.drawable.clear_button_disabled, + null)); + break; + case STT: + loadModelLayout.setVisibility(View.INVISIBLE); + chatLayout.setVisibility(View.VISIBLE); + + loadNewModelButton.setImageDrawable( + ResourcesCompat.getDrawable( + getResources(), + R.drawable.arrow_back_button_disabled, + null)); + loadNewModelButton.setEnabled(false); + statusProgress.setVisibility(View.GONE); + statusText.setVisibility(View.VISIBLE); + statusText.setText("Listening..."); + + int start = chatTextBuilder.length(); + chatTextBuilder.append("You:\n\n"); + chatTextBuilder.setSpan( + new ForegroundColorSpan(spanColour), + start, + start + 4, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + chatText.setText(chatTextBuilder); + + clearTextButton.setEnabled(false); + clearTextButton.setImageDrawable( + ResourcesCompat.getDrawable( + getResources(), + R.drawable.clear_button_disabled, + null)); + break; + case LLM_TTS: + loadModelLayout.setVisibility(View.INVISIBLE); + chatLayout.setVisibility(View.VISIBLE); + + loadNewModelButton.setImageDrawable( + ResourcesCompat.getDrawable( + getResources(), + R.drawable.arrow_back_button_disabled, + null)); + loadNewModelButton.setEnabled(false); + chatText.setText(""); + statusProgress.setVisibility(View.VISIBLE); + statusText.setVisibility(View.VISIBLE); + statusText.setText("Generating..."); + clearTextButton.setEnabled(false); + clearTextButton.setImageDrawable( + ResourcesCompat.getDrawable( + getResources(), + R.drawable.clear_button_disabled, + null)); + break; + default: + break; + } + + currentState = state; + }); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + + engineExecutor.shutdownNow(); + ttsSynthesizeExecutor.shutdownNow(); + ttsPlaybackExecutor.shutdownNow(); + + if (porcupine != null) { + porcupine.delete(); + porcupine = null; + } + + if (cheetah != null) { + cheetah.delete(); + cheetah = null; + } + + if (picollm != null) { + picollm.delete(); + picollm = null; + } + + if (orca != null) { + orca.delete(); + orca = null; + } + + if (voiceProcessor != null) { + voiceProcessor.clearFrameListeners(); + voiceProcessor.clearErrorListeners(); + } + } +} diff --git a/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/java/ai/picovoice/llmvoiceassistant/RTFProfiler.java b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/java/ai/picovoice/llmvoiceassistant/RTFProfiler.java new file mode 100644 index 0000000..c8acf77 --- /dev/null +++ b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/java/ai/picovoice/llmvoiceassistant/RTFProfiler.java @@ -0,0 +1,33 @@ +package ai.picovoice.llmvoiceassistant; + +public class RTFProfiler { + private final int sampleRate; + private double computeSec; + private double audioSec; + private double tickSec; + + public RTFProfiler(int sampleRate) { + this.sampleRate = sampleRate; + this.computeSec = 0.0; + this.audioSec = 0.0; + this.tickSec = 0.0; + } + + public void tick() { + this.tickSec = System.nanoTime() / 1e9; + } + + public void tock(short[] pcm) { + this.computeSec += (System.nanoTime() / 1e9) - this.tickSec; + if (pcm != null && pcm.length > 0) { + this.audioSec += pcm.length / (double) this.sampleRate; + } + } + + public double rtf() { + double rtf = this.computeSec / this.audioSec; + this.computeSec = 0.0; + this.audioSec = 0.0; + return rtf; + } +} diff --git a/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/java/ai/picovoice/llmvoiceassistant/TPSProfiler.java b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/java/ai/picovoice/llmvoiceassistant/TPSProfiler.java new file mode 100644 index 0000000..2501479 --- /dev/null +++ b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/java/ai/picovoice/llmvoiceassistant/TPSProfiler.java @@ -0,0 +1,30 @@ +package ai.picovoice.llmvoiceassistant; + +public class TPSProfiler { + private int numTokens; + private long startSec; + private long endSec; + + public TPSProfiler() { + this.numTokens = 0; + this.startSec = 0; + this.endSec = 0; + } + + public void tock() { + if (this.startSec == 0) { + this.startSec = System.nanoTime(); + } else { + this.endSec = System.nanoTime(); + this.numTokens += 1; + } + } + + public double tps() { + double tps = this.numTokens / ((this.endSec - this.startSec) / 1e9); + this.numTokens = 0; + this.startSec = 0; + this.endSec = 0; + return tps; + } +} diff --git a/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/arrow_back_button.xml b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/arrow_back_button.xml new file mode 100644 index 0000000..c5bd0aa --- /dev/null +++ b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/arrow_back_button.xml @@ -0,0 +1,5 @@ + + + diff --git a/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/arrow_back_button_disabled.xml b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/arrow_back_button_disabled.xml new file mode 100644 index 0000000..282e765 --- /dev/null +++ b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/arrow_back_button_disabled.xml @@ -0,0 +1,5 @@ + + + diff --git a/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/button_background.xml b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/button_background.xml new file mode 100644 index 0000000..8b6246e --- /dev/null +++ b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/button_background.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/button_disabled.xml b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/button_disabled.xml new file mode 100644 index 0000000..fe284b2 --- /dev/null +++ b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/button_disabled.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/button_secondary.xml b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/button_secondary.xml new file mode 100644 index 0000000..4399f41 --- /dev/null +++ b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/button_secondary.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/chat_text_background.xml b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/chat_text_background.xml new file mode 100644 index 0000000..63f6ba7 --- /dev/null +++ b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/chat_text_background.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/clear_button.xml b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/clear_button.xml new file mode 100644 index 0000000..59030f4 --- /dev/null +++ b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/clear_button.xml @@ -0,0 +1,5 @@ + + + diff --git a/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/clear_button_disabled.xml b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/clear_button_disabled.xml new file mode 100644 index 0000000..096c538 --- /dev/null +++ b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/clear_button_disabled.xml @@ -0,0 +1,5 @@ + + + diff --git a/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/error_view.xml b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/error_view.xml new file mode 100644 index 0000000..953beb3 --- /dev/null +++ b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/error_view.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/ic_launcher_background.xml b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..196e181 --- /dev/null +++ b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/ic_launcher_foreground.xml b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..d2923ae --- /dev/null +++ b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/prompt_button_arrow.xml b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/prompt_button_arrow.xml new file mode 100644 index 0000000..d81a72f --- /dev/null +++ b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/prompt_button_arrow.xml @@ -0,0 +1,5 @@ + + + diff --git a/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/prompt_button_disabled.xml b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/prompt_button_disabled.xml new file mode 100644 index 0000000..b506163 --- /dev/null +++ b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/prompt_button_disabled.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/prompt_button_enabled.xml b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/prompt_button_enabled.xml new file mode 100644 index 0000000..2d17f3f --- /dev/null +++ b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/prompt_button_enabled.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/prompt_text_disabled.xml b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/prompt_text_disabled.xml new file mode 100644 index 0000000..c71d313 --- /dev/null +++ b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/prompt_text_disabled.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/prompt_text_enabled.xml b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/prompt_text_enabled.xml new file mode 100644 index 0000000..12b3054 --- /dev/null +++ b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/drawable/prompt_text_enabled.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/layout/main_layout.xml b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/layout/main_layout.xml new file mode 100644 index 0000000..ca4a619 --- /dev/null +++ b/recipes/llm-voice-assistant/android/llm-voice-assistant/src/main/res/layout/main_layout.xml @@ -0,0 +1,149 @@ + + + + + + +