examples/simple-chatbot: move clients to client directory

This commit is contained in:
Aleix Conchillo Flaqué
2025-01-11 19:10:59 -08:00
parent a8ae79831e
commit a04a920e54
113 changed files with 6 additions and 6 deletions

View File

@@ -0,0 +1,10 @@
*.iml
.gradle
/local.properties
/.idea
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties

View File

@@ -0,0 +1,24 @@
BSD 2-Clause License
Copyright (c) 2024, Daily
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@@ -0,0 +1,20 @@
# Pipecat Simple Chatbot Client for Android
Demo app which connects to the `simple-chatbot` backend over RTVI.
## Screenshot
<img alt="screenshot" src="files/screenshot.jpg" width="400px" />
## How to run
```bash
./gradlew runDebug
```
Ensure that the `simple-chatbot` server is running as described in the parent README.
For a full walkthrough, describing how to set up the backend and forward the connection
to your Android device, please see the following blog post:
https://www.daily.co/blog/build-a-voice-agent-for-android-with-gemini-multimodal-live/

View File

@@ -0,0 +1,5 @@
plugins {
alias(libs.plugins.jetbrains.kotlin.android) apply false
alias(libs.plugins.android.application) apply false
alias(libs.plugins.compose.compiler) apply false
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

View File

@@ -0,0 +1,23 @@
# 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

View File

@@ -0,0 +1,34 @@
[versions]
accompanistPermissions = "0.34.0"
agp = "8.7.3"
constraintlayoutCompose = "1.1.0"
pipecatClientDaily = "0.3.2"
kotlin = "2.0.20"
coreKtx = "1.15.0"
lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.9.3"
composeBom = "2024.12.01"
kotlinxSerializationJson = "1.7.1"
kotlinxSerializationPlugin = "2.0.20"
[libraries]
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" }
androidx-constraintlayout-compose = { module = "androidx.constraintlayout:constraintlayout-compose", version.ref = "constraintlayoutCompose" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
pipecat-client-daily = { module = "ai.pipecat:daily-transport", version.ref = "pipecatClientDaily" }
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-material3 = { group = "androidx.compose.material3", name = "material3" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
[plugins]
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
android-application = { id = "com.android.application", version.ref = "agp" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
jetbrains-kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinxSerializationPlugin" }

View File

@@ -0,0 +1,6 @@
#Mon Aug 05 13:01:27 BST 2024
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

185
examples/simple-chatbot/client/android/gradlew vendored Executable file
View File

@@ -0,0 +1,185 @@
#!/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" "$@"

View File

@@ -0,0 +1,89 @@
@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

View File

@@ -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 = "Pipecat Simple Chatbot Client"
include(":simple-chatbot-client")

View File

@@ -0,0 +1 @@
/build

View File

@@ -0,0 +1,75 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.jetbrains.kotlin.android)
alias(libs.plugins.jetbrains.kotlin.serialization)
alias(libs.plugins.compose.compiler)
}
android {
namespace = "ai.pipecat.simple_chatbot_client"
compileSdk = 35
defaultConfig {
applicationId = "ai.pipecat.simple_chatbot_client"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0"
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
compose = true
buildConfig = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.1"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
implementation(libs.pipecat.client.daily)
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(libs.accompanist.permissions)
implementation(libs.androidx.constraintlayout.compose)
implementation(libs.kotlinx.serialization.json)
androidTestImplementation(platform(libs.androidx.compose.bom))
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
}

View File

@@ -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

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<application
android:name=".RTVIApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.RTVIClient">
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize"
android:theme="@style/Theme.RTVIClient">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,257 @@
package ai.pipecat.simple_chatbot_client
import ai.pipecat.simple_chatbot_client.ui.InCallLayout
import ai.pipecat.simple_chatbot_client.ui.PermissionScreen
import ai.pipecat.simple_chatbot_client.ui.theme.Colors
import ai.pipecat.simple_chatbot_client.ui.theme.RTVIClientTheme
import ai.pipecat.simple_chatbot_client.ui.theme.TextStyles
import ai.pipecat.simple_chatbot_client.ui.theme.textFieldColors
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.annotation.DrawableRes
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.imePadding
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.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val voiceClientManager = VoiceClientManager(this)
setContent {
RTVIClientTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Box(
Modifier
.fillMaxSize()
.padding(innerPadding)
) {
PermissionScreen()
val vcState = voiceClientManager.state.value
if (vcState != null) {
InCallLayout(voiceClientManager)
} else {
ConnectSettings(voiceClientManager)
}
voiceClientManager.errors.firstOrNull()?.let { errorText ->
val dismiss: () -> Unit = { voiceClientManager.errors.removeAt(0) }
AlertDialog(
onDismissRequest = dismiss,
confirmButton = {
Button(onClick = dismiss) {
Text(
text = "OK",
fontSize = 14.sp,
fontWeight = FontWeight.W700,
color = Color.White,
style = TextStyles.base
)
}
},
containerColor = Color.White,
title = {
Text(
text = "Error",
fontSize = 22.sp,
fontWeight = FontWeight.W600,
color = Color.Black,
style = TextStyles.base
)
},
text = {
Text(
text = errorText.message,
fontSize = 16.sp,
fontWeight = FontWeight.W400,
color = Color.Black,
style = TextStyles.base
)
}
)
}
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConnectSettings(
voiceClientManager: VoiceClientManager,
) {
val scrollState = rememberScrollState()
val start = {
val backendUrl = Preferences.backendUrl.value
voiceClientManager.start(baseUrl = backendUrl!!)
}
Box(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.imePadding()
.padding(20.dp),
contentAlignment = Alignment.Center
) {
Box(
Modifier
.fillMaxWidth()
.shadow(2.dp, RoundedCornerShape(16.dp))
.clip(RoundedCornerShape(16.dp))
.background(Colors.mainSurfaceBackground)
) {
Column(
Modifier
.fillMaxWidth()
.padding(
vertical = 24.dp,
horizontal = 28.dp
)
) {
Spacer(modifier = Modifier.height(12.dp))
Text(
modifier = Modifier.align(Alignment.CenterHorizontally),
text = "Connect to an RTVI server",
fontSize = 22.sp,
fontWeight = FontWeight.W700,
style = TextStyles.base
)
Spacer(modifier = Modifier.height(36.dp))
Text(
text = "Backend URL",
fontSize = 16.sp,
fontWeight = FontWeight.W400,
style = TextStyles.base
)
Spacer(modifier = Modifier.height(12.dp))
TextField(
modifier = Modifier
.fillMaxWidth()
.border(1.dp, Colors.textFieldBorder, RoundedCornerShape(12.dp)),
value = Preferences.backendUrl.value ?: "",
onValueChange = { Preferences.backendUrl.value = it },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Next
),
colors = textFieldColors(),
shape = RoundedCornerShape(12.dp)
)
Spacer(modifier = Modifier.height(36.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
ConnectDialogButton(
modifier = Modifier.weight(1f),
onClick = start,
text = "Connect",
foreground = Color.White,
background = Colors.buttonNormal,
border = Colors.buttonNormal
)
}
}
}
}
}
@Composable
private fun ConnectDialogButton(
onClick: () -> Unit,
text: String,
foreground: Color,
background: Color,
border: Color,
modifier: Modifier = Modifier,
@DrawableRes icon: Int? = null,
) {
val shape = RoundedCornerShape(8.dp)
Row(
modifier
.border(1.dp, border, shape)
.clip(shape)
.background(background)
.clickable(onClick = onClick)
.padding(vertical = 10.dp, horizontal = 24.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
if (icon != null) {
Icon(
modifier = Modifier.size(24.dp),
painter = painterResource(icon),
tint = foreground,
contentDescription = null
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(
text = text,
fontSize = 16.sp,
fontWeight = FontWeight.W500,
color = foreground
)
}
}

View File

@@ -0,0 +1,75 @@
package ai.pipecat.simple_chatbot_client
import android.content.Context
import android.content.SharedPreferences
import androidx.compose.runtime.mutableStateOf
import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.Json
private val JSON_INSTANCE = Json { ignoreUnknownKeys = true }
object Preferences {
private const val PREF_BACKEND_URL = "backend_url"
private lateinit var prefs: SharedPreferences
fun initAppStart(context: Context) {
prefs = context.applicationContext.getSharedPreferences("prefs", Context.MODE_PRIVATE)
listOf(backendUrl).forEach { it.init() }
}
private fun getString(key: String): String? = prefs.getString(key, null)
interface BasePref {
fun init()
}
class StringPref(private val key: String): BasePref {
private val cachedValue = mutableStateOf<String?>(null)
override fun init() {
cachedValue.value = getString(key)
prefs.registerOnSharedPreferenceChangeListener { _, changedKey ->
if (key == changedKey) {
cachedValue.value = getString(key)
}
}
}
var value: String?
get() = cachedValue.value
set(newValue) {
cachedValue.value = newValue
prefs.edit().putString(key, newValue).apply()
}
}
class JsonPref<E>(private val key: String, private var serializer: KSerializer<E>): BasePref {
private val cachedValue = mutableStateOf<E?>(null)
private fun lookupValue(): E? =
getString(key)?.let { JSON_INSTANCE.decodeFromString(serializer, it) }
override fun init() {
cachedValue.value = lookupValue()
prefs.registerOnSharedPreferenceChangeListener { _, changedKey ->
if (key == changedKey) {
cachedValue.value = lookupValue()
}
}
}
var value: E?
get() = cachedValue.value
set(newValue) {
cachedValue.value = newValue
prefs.edit()
.putString(key, newValue?.let { JSON_INSTANCE.encodeToString(serializer, it) })
.apply()
}
}
val backendUrl = StringPref(PREF_BACKEND_URL)
}

View File

@@ -0,0 +1,10 @@
package ai.pipecat.simple_chatbot_client
import android.app.Application
class RTVIApplication : Application() {
override fun onCreate() {
super.onCreate()
Preferences.initAppStart(this)
}
}

View File

@@ -0,0 +1,193 @@
package ai.pipecat.simple_chatbot_client
import ai.pipecat.client.RTVIClient
import ai.pipecat.client.RTVIClientOptions
import ai.pipecat.client.RTVIClientParams
import ai.pipecat.client.RTVIEventCallbacks
import ai.pipecat.client.daily.DailyTransport
import ai.pipecat.client.result.Future
import ai.pipecat.client.result.RTVIError
import ai.pipecat.client.result.Result
import ai.pipecat.client.types.ActionDescription
import ai.pipecat.client.types.Participant
import ai.pipecat.client.types.PipecatMetrics
import ai.pipecat.client.types.RTVIURLEndpoints
import ai.pipecat.client.types.ServiceConfig
import ai.pipecat.client.types.Tracks
import ai.pipecat.client.types.Transcript
import ai.pipecat.client.types.TransportState
import ai.pipecat.simple_chatbot_client.utils.Timestamp
import android.content.Context
import android.util.Log
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
@Immutable
data class Error(val message: String)
@Stable
class VoiceClientManager(private val context: Context) {
companion object {
private const val TAG = "VoiceClientManager"
}
private val client = mutableStateOf<RTVIClient?>(null)
val state = mutableStateOf<TransportState?>(null)
val errors = mutableStateListOf<Error>()
val actionDescriptions =
mutableStateOf<Result<List<ActionDescription>, RTVIError>?>(null)
val expiryTime = mutableStateOf<Timestamp?>(null)
val botReady = mutableStateOf(false)
val botIsTalking = mutableStateOf(false)
val userIsTalking = mutableStateOf(false)
val botAudioLevel = mutableFloatStateOf(0f)
val userAudioLevel = mutableFloatStateOf(0f)
val mic = mutableStateOf(false)
val camera = mutableStateOf(false)
val tracks = mutableStateOf<Tracks?>(null)
private fun <E> Future<E, RTVIError>.displayErrors() = withErrorCallback {
Log.e(TAG, "Future resolved with error: ${it.description}", it.exception)
errors.add(Error(it.description))
}
fun start(baseUrl: String) {
if (client.value != null) {
return
}
val options = RTVIClientOptions(
params = RTVIClientParams(
baseUrl = baseUrl,
endpoints = RTVIURLEndpoints(),
)
)
state.value = TransportState.Disconnected
val callbacks = object : RTVIEventCallbacks() {
override fun onTransportStateChanged(state: TransportState) {
this@VoiceClientManager.state.value = state
}
override fun onBackendError(message: String) {
"Error from backend: $message".let {
Log.e(TAG, it)
errors.add(Error(it))
}
}
override fun onBotReady(version: String, config: List<ServiceConfig>) {
Log.i(TAG, "Bot ready. Version $version, config: $config")
botReady.value = true
client.value?.describeActions()?.withCallback {
actionDescriptions.value = it
}
}
override fun onPipecatMetrics(data: PipecatMetrics) {
Log.i(TAG, "Pipecat metrics: $data")
}
override fun onUserTranscript(data: Transcript) {
Log.i(TAG, "User transcript: $data")
}
override fun onBotTranscript(text: String) {
Log.i(TAG, "Bot transcript: $text")
}
override fun onBotStartedSpeaking() {
Log.i(TAG, "Bot started speaking")
botIsTalking.value = true
}
override fun onBotStoppedSpeaking() {
Log.i(TAG, "Bot stopped speaking")
botIsTalking.value = false
}
override fun onUserStartedSpeaking() {
Log.i(TAG, "User started speaking")
userIsTalking.value = true
}
override fun onUserStoppedSpeaking() {
Log.i(TAG, "User stopped speaking")
userIsTalking.value = false
}
override fun onTracksUpdated(tracks: Tracks) {
this@VoiceClientManager.tracks.value = tracks
}
override fun onInputsUpdated(camera: Boolean, mic: Boolean) {
this@VoiceClientManager.camera.value = camera
this@VoiceClientManager.mic.value = mic
}
override fun onConnected() {
expiryTime.value = client.value?.expiry?.let(Timestamp::ofEpochSecs)
}
override fun onDisconnected() {
expiryTime.value = null
actionDescriptions.value = null
botIsTalking.value = false
userIsTalking.value = false
state.value = null
actionDescriptions.value = null
botReady.value = false
tracks.value = null
client.value?.release()
client.value = null
}
override fun onUserAudioLevel(level: Float) {
userAudioLevel.floatValue = level
}
override fun onRemoteAudioLevel(level: Float, participant: Participant) {
botAudioLevel.floatValue = level
}
}
val client = RTVIClient(DailyTransport.Factory(context), callbacks, options)
client.connect().displayErrors().withErrorCallback {
callbacks.onDisconnected()
}
this.client.value = client
}
fun enableCamera(enabled: Boolean) {
client.value?.enableCam(enabled)?.displayErrors()
}
fun enableMic(enabled: Boolean) {
client.value?.enableMic(enabled)?.displayErrors()
}
fun toggleCamera() = enableCamera(!camera.value)
fun toggleMic() = enableMic(!mic.value)
fun stop() {
client.value?.disconnect()?.displayErrors()
}
}

View File

@@ -0,0 +1,70 @@
package ai.pipecat.simple_chatbot_client.ui
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.semantics.clearAndSetSemantics
@Composable
fun ListeningAnimation(
modifier: Modifier,
active: Boolean,
level: Float,
color: Color,
) {
val infiniteTransition = rememberInfiniteTransition("listeningAnimation")
val loopState by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = Math.PI.toFloat() * 2f,
animationSpec = infiniteRepeatable(tween(durationMillis = 1000, easing = LinearEasing)),
label = "listeningAnimationLoopState"
)
val activeFraction by animateFloatAsState(
if (active) {
Math.pow(level.toDouble(), 0.3).toFloat()
} else {
0f
}
)
Canvas(modifier.clearAndSetSemantics { }) {
val strokeWidthPx = size.width / 12
val lineCount = 5
for (i in 1..lineCount) {
val sine = Math.sin(loopState + 0.9 * i)
val fraction = activeFraction * ((sine + 1) / 2).toFloat()
val x = (size.width / (lineCount + 1)) * i
val yMax = size.height * 0.25f
val yMin = size.height * 0.5f
val y = yMin + (yMax - yMin) * fraction
val yEnd = size.height - y
this.drawLine(
start = Offset(x, y),
end = Offset(x, yEnd),
color = color,
strokeWidth = strokeWidthPx,
cap = StrokeCap.Round
)
}
}
}

View File

@@ -0,0 +1,93 @@
package ai.pipecat.simple_chatbot_client.ui
import ai.pipecat.simple_chatbot_client.ui.theme.Colors
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.FloatState
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Composable
fun BotIndicator(
modifier: Modifier,
isReady: Boolean,
isTalking: State<Boolean>,
audioLevel: FloatState,
) {
Box(
modifier = modifier.padding(15.dp),
contentAlignment = Alignment.Center
) {
val color by animateColorAsState(if (isTalking.value || !isReady) {
Color.Black
} else {
Colors.botIndicatorBackground
})
Box(
Modifier
.aspectRatio(1f)
.fillMaxSize()
.shadow(20.dp, CircleShape)
.border(12.dp, Color.White, CircleShape)
.border(1.dp, Colors.lightGrey, CircleShape)
.clip(CircleShape)
.background(color)
.padding(50.dp),
contentAlignment = Alignment.Center,
) {
AnimatedContent(
targetState = isReady
) { isReadyVal ->
if (isReadyVal) {
ListeningAnimation(
modifier = Modifier.fillMaxSize(),
active = isTalking.value,
level = audioLevel.floatValue,
color = Color.White
)
} else {
CircularProgressIndicator(
modifier = Modifier.size(180.dp),
color = Color.White,
strokeWidth = 12.dp,
strokeCap = StrokeCap.Round,
trackColor = color
)
}
}
}
}
}
@Composable
@Preview
fun PreviewBotIndicator() {
BotIndicator(
modifier = Modifier,
isReady = false,
isTalking = remember { mutableStateOf(true) },
audioLevel = remember { mutableFloatStateOf(1.0f) }
)
}

View File

@@ -0,0 +1,89 @@
package ai.pipecat.simple_chatbot_client.ui
import ai.pipecat.simple_chatbot_client.R
import ai.pipecat.simple_chatbot_client.ui.theme.Colors
import androidx.annotation.DrawableRes
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
private fun FooterButton(
modifier: Modifier,
onClick: () -> Unit,
@DrawableRes icon: Int,
text: String,
foreground: Color,
background: Color,
border: Color,
) {
val shape = RoundedCornerShape(12.dp)
Row(
modifier
.border(1.dp, border, shape)
.clip(shape)
.background(background)
.clickable(onClick = onClick)
.padding(vertical = 10.dp, horizontal = 18.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(
modifier = Modifier.size(24.dp),
painter = painterResource(icon),
tint = foreground,
contentDescription = null
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = text,
fontSize = 14.sp,
fontWeight = FontWeight.W600,
color = foreground
)
}
}
@Composable
fun ColumnScope.InCallFooter(
onClickEnd: () -> Unit,
) {
Row(Modifier
.fillMaxWidth(0.5f)
.padding(15.dp)
.align(Alignment.CenterHorizontally)
) {
FooterButton(
modifier = Modifier.weight(1f),
onClick = onClickEnd,
icon = R.drawable.phone_hangup,
text = "End",
foreground = Color.White,
background = Colors.endButton,
border = Colors.endButton
)
}
}

View File

@@ -0,0 +1,49 @@
package ai.pipecat.simple_chatbot_client.ui
import ai.pipecat.simple_chatbot_client.utils.Timestamp
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout
@Composable
fun InCallHeader(
expiryTime: Timestamp?
) {
ConstraintLayout(
Modifier
.fillMaxWidth()
.padding(vertical = 15.dp)
) {
val refTimer = createRef()
AnimatedContent(
modifier = Modifier.constrainAs(refTimer) {
top.linkTo(parent.top)
bottom.linkTo(parent.bottom)
end.linkTo(parent.end)
},
targetState = expiryTime,
transitionSpec = { fadeIn() togetherWith fadeOut() }
) { expiryTimeVal ->
if (expiryTimeVal != null) {
Timer(expiryTime = expiryTimeVal, modifier = Modifier)
}
}
}
}
@Composable
@Preview
fun PreviewInCallHeader() {
InCallHeader(
Timestamp.now() + java.time.Duration.ofMinutes(3)
)
}

View File

@@ -0,0 +1,70 @@
package ai.pipecat.simple_chatbot_client.ui
import ai.pipecat.simple_chatbot_client.VoiceClientManager
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun InCallLayout(voiceClientManager: VoiceClientManager) {
val localCam by remember { derivedStateOf { voiceClientManager.tracks.value?.local?.video } }
Column(Modifier.fillMaxSize()) {
InCallHeader(expiryTime = voiceClientManager.expiryTime.value)
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically)
) {
BotIndicator(
modifier = Modifier,
isReady = voiceClientManager.botReady.value,
isTalking = voiceClientManager.botIsTalking,
audioLevel = voiceClientManager.botAudioLevel
)
Row(
verticalAlignment = Alignment.CenterVertically
) {
UserMicButton(
onClick = voiceClientManager::toggleMic,
micEnabled = voiceClientManager.mic.value,
modifier = Modifier,
isTalking = voiceClientManager.userIsTalking,
audioLevel = voiceClientManager.userAudioLevel
)
UserCamButton(
onClick = voiceClientManager::toggleCamera,
camEnabled = voiceClientManager.camera.value,
camTrackId = localCam,
modifier = Modifier
)
}
}
}
InCallFooter(
onClickEnd = voiceClientManager::stop
)
}
}

View File

@@ -0,0 +1,99 @@
package ai.pipecat.simple_chatbot_client.ui
import ai.pipecat.simple_chatbot_client.ui.theme.Colors
import ai.pipecat.simple_chatbot_client.ui.theme.TextStyles
import android.Manifest
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun PermissionScreen() {
val cameraPermission = rememberPermissionState(Manifest.permission.CAMERA)
val micPermission = rememberPermissionState(Manifest.permission.RECORD_AUDIO)
val requestPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { isGranted ->
Log.i("MainActivity", "Permissions granted: $isGranted")
}
if (!cameraPermission.status.isGranted || !micPermission.status.isGranted) {
Dialog(
onDismissRequest = {},
) {
val dialogShape = RoundedCornerShape(16.dp)
Column(
Modifier
.shadow(6.dp, dialogShape)
.border(2.dp, Colors.logoBorder, dialogShape)
.clip(dialogShape)
.background(Color.White)
.padding(28.dp)
) {
Text(
text = "Permissions",
fontSize = 24.sp,
fontWeight = FontWeight.W700,
style = TextStyles.base
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Please grant camera and mic permissions to continue",
fontSize = 18.sp,
fontWeight = FontWeight.W400,
style = TextStyles.base
)
Spacer(modifier = Modifier.height(36.dp))
Button(
modifier = Modifier.align(Alignment.End),
shape = RoundedCornerShape(12.dp),
onClick = {
requestPermissionLauncher.launch(
arrayOf(
Manifest.permission.CAMERA,
Manifest.permission.RECORD_AUDIO
)
)
}
) {
Text(
text = "Grant permissions",
fontSize = 16.sp,
fontWeight = FontWeight.W700,
style = TextStyles.base
)
}
}
}
}
}

View File

@@ -0,0 +1,72 @@
package ai.pipecat.simple_chatbot_client.ui
import ai.pipecat.simple_chatbot_client.R
import ai.pipecat.simple_chatbot_client.ui.theme.Colors
import ai.pipecat.simple_chatbot_client.utils.Timestamp
import ai.pipecat.simple_chatbot_client.utils.formatTimer
import ai.pipecat.simple_chatbot_client.utils.rtcStateSecs
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import java.time.Duration
@Composable
fun Timer(
expiryTime: Timestamp,
modifier: Modifier,
) {
val now by rtcStateSecs()
val shape = RoundedCornerShape(
topStart = 12.dp,
bottomStart = 12.dp,
)
Row(
modifier = modifier
.widthIn(min = 100.dp)
.clip(shape)
.background(Colors.lightGrey)
.padding(top = 12.dp, bottom = 12.dp, start = 12.dp, end = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(id = R.drawable.timer_outline),
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = Colors.expiryTimerForeground
)
Spacer(Modifier.width(8.dp))
Text(
text = formatTimer(duration = expiryTime - now),
fontSize = 16.sp,
fontWeight = FontWeight.W600,
color = Colors.expiryTimerForeground
)
}
}
@Composable
@Preview
fun PreviewExpiryTimer() {
Timer(Timestamp.now() + Duration.ofMinutes(5), Modifier)
}

View File

@@ -0,0 +1,110 @@
package ai.pipecat.simple_chatbot_client.ui
import ai.pipecat.client.daily.VoiceClientVideoView
import ai.pipecat.client.types.MediaTrackId
import ai.pipecat.simple_chatbot_client.R
import ai.pipecat.simple_chatbot_client.ui.theme.Colors
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
@Composable
fun UserCamButton(
onClick: () -> Unit,
camEnabled: Boolean,
camTrackId: MediaTrackId?,
modifier: Modifier,
) {
Box(
modifier = modifier.padding(15.dp).size(96.dp),
contentAlignment = Alignment.Center
) {
val color by animateColorAsState(
if (camEnabled) {
Colors.unmutedMicBackground
} else {
Colors.mutedMicBackground
}
)
Box(
Modifier
.fillMaxSize()
.shadow(3.dp, CircleShape)
.border(6.dp, Color.White, CircleShape)
.border(1.dp, Colors.lightGrey, CircleShape)
.clip(CircleShape)
.background(color)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
if (camTrackId != null) {
AndroidView(
factory = { context ->
VoiceClientVideoView(context)
},
update = { view ->
view.voiceClientTrack = camTrackId
}
)
} else {
Icon(
modifier = Modifier.size(30.dp),
painter = painterResource(
if (camEnabled) {
R.drawable.video
} else {
R.drawable.video_off
}
),
tint = Color.White,
contentDescription = if (camEnabled) {
"Disable camera"
} else {
"Enable camera"
},
)
}
}
}
}
@Composable
@Preview
fun PreviewUserCamButton() {
UserCamButton(
onClick = {},
camTrackId = null,
camEnabled = true,
modifier = Modifier,
)
}
@Composable
@Preview
fun PreviewUserCamButtonMuted() {
UserCamButton(
onClick = {},
camTrackId = null,
camEnabled = false,
modifier = Modifier,
)
}

View File

@@ -0,0 +1,114 @@
package ai.pipecat.simple_chatbot_client.ui
import ai.pipecat.simple_chatbot_client.R
import ai.pipecat.simple_chatbot_client.ui.theme.Colors
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.FloatState
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Composable
fun UserMicButton(
onClick: () -> Unit,
micEnabled: Boolean,
modifier: Modifier,
isTalking: State<Boolean>,
audioLevel: FloatState,
) {
Box(
modifier = modifier.padding(15.dp),
contentAlignment = Alignment.Center
) {
val borderThickness by animateDpAsState(
if (isTalking.value) {
(24.dp * Math.pow(audioLevel.floatValue.toDouble(), 0.3).toFloat()) + 3.dp
} else {
6.dp
}
)
val color by animateColorAsState(
if (!micEnabled) {
Colors.mutedMicBackground
} else if (isTalking.value) {
Color.Black
} else {
Colors.unmutedMicBackground
}
)
Box(
Modifier
.shadow(3.dp, CircleShape)
.border(borderThickness, Color.White, CircleShape)
.border(1.dp, Colors.lightGrey, CircleShape)
.clip(CircleShape)
.background(color)
.clickable(onClick = onClick)
.padding(36.dp),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier.size(48.dp),
painter = painterResource(
if (micEnabled) {
R.drawable.microphone
} else {
R.drawable.microphone_off
}
),
tint = Color.White,
contentDescription = if (micEnabled) {
"Mute microphone"
} else {
"Unmute microphone"
},
)
}
}
}
@Composable
@Preview
fun PreviewUserMicButton() {
UserMicButton(
onClick = {},
micEnabled = true,
modifier = Modifier,
isTalking = remember { mutableStateOf(false) },
audioLevel = remember { mutableFloatStateOf(1.0f) }
)
}
@Composable
@Preview
fun PreviewUserMicButtonMuted() {
UserMicButton(
onClick = {},
micEnabled = false,
modifier = Modifier,
isTalking = remember { mutableStateOf(false) },
audioLevel = remember { mutableFloatStateOf(1.0f) }
)
}

View File

@@ -0,0 +1,22 @@
package ai.pipecat.simple_chatbot_client.ui.theme
import androidx.compose.ui.graphics.Color
object Colors {
val buttonNormal = Color(0xFF374151)
val buttonWarning = Color(0xFFE53935)
val buttonSection = Color(0xFFDFF1FF)
val activityBackground = Color(0xFFF9FAFB)
val mainSurfaceBackground = Color.White
val lightGrey = Color(0x7FE5E7EB)
val expiryTimerForeground = Color.Black
val logoBorder = Color(0xFFE2E8F0)
val endButton = Color(0xFF0F172A)
val textFieldBorder = Color(0xFFDFE6EF)
val botIndicatorBackground = Color(0xFF374151)
val mutedMicBackground = Color(0xFFF04A4A)
val unmutedMicBackground = Color(0xFF616978)
}

View File

@@ -0,0 +1,36 @@
package ai.pipecat.simple_chatbot_client.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val LightColorScheme = lightColorScheme(
primary = Colors.buttonNormal,
secondary = Colors.buttonWarning,
background = Colors.activityBackground,
surface = Colors.mainSurfaceBackground
)
@Composable
fun RTVIClientTheme(
content: @Composable () -> Unit
) {
val colorScheme = LightColorScheme
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}
@Composable
fun textFieldColors() = TextFieldDefaults.colors().copy(
unfocusedContainerColor = Colors.activityBackground,
focusedContainerColor = Colors.activityBackground,
focusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
)

View File

@@ -0,0 +1,40 @@
package ai.pipecat.simple_chatbot_client.ui.theme
import ai.pipecat.simple_chatbot_client.R
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
object TextStyles {
val base = TextStyle(fontFamily = FontFamily(Font(R.font.inter)))
}
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)

View File

@@ -0,0 +1,21 @@
package ai.pipecat.simple_chatbot_client.utils
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flow
private val rtcFlowSecs = flow {
while(true) {
val now = Timestamp.now().toEpochMilli()
val rounded = ((now + 500) / 1000) * 1000
emit(Timestamp.ofEpochMilli(rounded))
val target = rounded + 1000
delay(target - now)
}
}
@Composable
fun rtcStateSecs() = rtcFlowSecs.collectAsState(initial = Timestamp.now())

View File

@@ -0,0 +1,64 @@
package ai.pipecat.simple_chatbot_client.utils
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import java.time.Duration
import java.time.Instant
import java.time.format.DateTimeFormatter
import java.util.Date
// Wrapper for Compose stability
@Immutable
@JvmInline
value class Timestamp(
val value: Instant
) : Comparable<Timestamp> {
val isInPast: Boolean
get() = value < Instant.now()
val isInFuture: Boolean
get() = value > Instant.now()
fun toEpochMilli() = value.toEpochMilli()
operator fun plus(duration: Duration) = Timestamp(value + duration)
operator fun minus(duration: Duration) = Timestamp(value - duration)
operator fun minus(rhs: Timestamp) = Duration.between(rhs.value, value)
override operator fun compareTo(other: Timestamp) = value.compareTo(other.value)
fun toISOString(): String = DateTimeFormatter.ISO_INSTANT.format(value)
override fun toString() = toISOString()
companion object {
fun now() = Timestamp(Instant.now())
fun ofEpochMilli(value: Long) = Timestamp(Instant.ofEpochMilli(value))
fun ofEpochSecs(value: Long) = ofEpochMilli(value * 1000)
fun parse(value: CharSequence) = Timestamp(Instant.parse(value))
fun from(date: Date) = Timestamp(date.toInstant())
}
}
@Composable
fun formatTimer(duration: Duration): String {
if (duration.seconds < 0) {
return "0s"
}
val mins = duration.seconds / 60
val secs = duration.seconds % 60
return if (mins == 0L) {
"${secs}s"
} else {
"${mins}m ${secs}s"
}
}

View File

@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@@ -0,0 +1 @@
<!-- drawable/microphone.xml --><vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#000000" android:pathData="M12,2A3,3 0 0,1 15,5V11A3,3 0 0,1 12,14A3,3 0 0,1 9,11V5A3,3 0 0,1 12,2M19,11C19,14.53 16.39,17.44 13,17.93V21H11V17.93C7.61,17.44 5,14.53 5,11H7A5,5 0 0,0 12,16A5,5 0 0,0 17,11H19Z" /></vector>

View File

@@ -0,0 +1 @@
<!-- drawable/microphone_off.xml --><vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#000000" android:pathData="M19,11C19,12.19 18.66,13.3 18.1,14.28L16.87,13.05C17.14,12.43 17.3,11.74 17.3,11H19M15,11.16L9,5.18V5A3,3 0 0,1 12,2A3,3 0 0,1 15,5V11L15,11.16M4.27,3L21,19.73L19.73,21L15.54,16.81C14.77,17.27 13.91,17.58 13,17.72V21H11V17.72C7.72,17.23 5,14.41 5,11H6.7C6.7,14 9.24,16.1 12,16.1C12.81,16.1 13.6,15.91 14.31,15.58L12.65,13.92L12,14A3,3 0 0,1 9,11V10.28L3,4.27L4.27,3Z" /></vector>

View File

@@ -0,0 +1 @@
<!-- drawable/phone_hangup.xml --><vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#000000" android:pathData="M12,9C10.4,9 8.85,9.25 7.4,9.72V12.82C7.4,13.22 7.17,13.56 6.84,13.72C5.86,14.21 4.97,14.84 4.17,15.57C4,15.75 3.75,15.86 3.5,15.86C3.2,15.86 2.95,15.74 2.77,15.56L0.29,13.08C0.11,12.9 0,12.65 0,12.38C0,12.1 0.11,11.85 0.29,11.67C3.34,8.77 7.46,7 12,7C16.54,7 20.66,8.77 23.71,11.67C23.89,11.85 24,12.1 24,12.38C24,12.65 23.89,12.9 23.71,13.08L21.23,15.56C21.05,15.74 20.8,15.86 20.5,15.86C20.25,15.86 20,15.75 19.82,15.57C19.03,14.84 18.14,14.21 17.16,13.72C16.83,13.56 16.6,13.22 16.6,12.82V9.72C15.15,9.25 13.6,9 12,9Z" /></vector>

View File

@@ -0,0 +1 @@
<!-- drawable/timer_outline.xml --><vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#000000" android:pathData="M12,20A7,7 0 0,1 5,13A7,7 0 0,1 12,6A7,7 0 0,1 19,13A7,7 0 0,1 12,20M19.03,7.39L20.45,5.97C20,5.46 19.55,5 19.04,4.56L17.62,6C16.07,4.74 14.12,4 12,4A9,9 0 0,0 3,13A9,9 0 0,0 12,22C17,22 21,17.97 21,13C21,10.88 20.26,8.93 19.03,7.39M11,14H13V8H11M15,1H9V3H15V1Z" /></vector>

View File

@@ -0,0 +1 @@
<!-- drawable/video.xml --><vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#000000" android:pathData="M17,10.5V7A1,1 0 0,0 16,6H4A1,1 0 0,0 3,7V17A1,1 0 0,0 4,18H16A1,1 0 0,0 17,17V13.5L21,17.5V6.5L17,10.5Z" /></vector>

View File

@@ -0,0 +1 @@
<!-- drawable/video_off.xml --><vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#000000" android:pathData="M3.27,2L2,3.27L4.73,6H4A1,1 0 0,0 3,7V17A1,1 0 0,0 4,18H16C16.2,18 16.39,17.92 16.54,17.82L19.73,21L21,19.73M21,6.5L17,10.5V7A1,1 0 0,0 16,6H9.82L21,17.18V6.5Z" /></vector>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">Pipecat Simple Chatbot Client</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.RTVIClient" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View File

@@ -0,0 +1,2 @@
/SimpleChatbot.xcodeproj/xcuserdata/
/SimpleChatbot.xcodeproj/project.xcworkspace/xcuserdata/

View File

@@ -0,0 +1,18 @@
# iOS implementation
Basic implementation using the [Pipecat iOS SDK](https://docs.pipecat.ai/client/ios/introduction).
## Prerequisites
1. Run the bot server. See the [server README](../../server).
2. Install [Xcode 15](https://developer.apple.com/xcode/), and set up your device [to run your own applications](https://developer.apple.com/documentation/xcode/distributing-your-app-to-registered-devices).
## Running locally
1. Clone this repository locally.
2. Open the SimpleChatbot.xcodeproj in Xcode.
3. Tell Xcode to update its Package Dependencies by clicking File -> Packages -> Update to Latest Package Versions.
4. Build the project.
5. Run the project on your device.
6. Connect to the URL you are testing.

View File

@@ -0,0 +1,704 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 56;
objects = {
/* Begin PBXBuildFile section */
90031FA72C616EE700408370 /* SimpleChatbotApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90031FA62C616EE700408370 /* SimpleChatbotApp.swift */; };
90031FA92C616EE700408370 /* PreJoinView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90031FA82C616EE700408370 /* PreJoinView.swift */; };
90031FAB2C616EE800408370 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 90031FAA2C616EE800408370 /* Assets.xcassets */; };
90031FAE2C616EE800408370 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 90031FAD2C616EE800408370 /* Preview Assets.xcassets */; };
90031FB82C616EE900408370 /* SimpleChatbotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90031FB72C616EE900408370 /* SimpleChatbotTests.swift */; };
90031FC22C616EE900408370 /* SimpleChatbotUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90031FC12C616EE900408370 /* SimpleChatbotUITests.swift */; };
90031FC42C616EE900408370 /* SimpleChatbotUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90031FC32C616EE900408370 /* SimpleChatbotUITestsLaunchTests.swift */; };
90031FDC2C6D5DD700408370 /* ToastModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90031FDB2C6D5DD700408370 /* ToastModifier.swift */; };
90ABB98E2C735ED6000D9CC7 /* MeetingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90ABB98D2C735ED6000D9CC7 /* MeetingView.swift */; };
90ABB9902C736A8B000D9CC7 /* WaveformView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90ABB98F2C736A8B000D9CC7 /* WaveformView.swift */; };
90ABB9932C73820D000D9CC7 /* MicrophoneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90ABB9922C73820D000D9CC7 /* MicrophoneView.swift */; };
90ABB9982C738356000D9CC7 /* CustomColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90ABB9972C738356000D9CC7 /* CustomColors.swift */; };
90ABB99A2C73A6A9000D9CC7 /* MockCallContainerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90ABB9992C73A6A9000D9CC7 /* MockCallContainerModel.swift */; };
90ABB99D2C73C2D1000D9CC7 /* CallContainerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90ABB99C2C73C2D1000D9CC7 /* CallContainerModel.swift */; };
90ABB9A32C74E1CE000D9CC7 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90ABB9A22C74E1CE000D9CC7 /* SettingsView.swift */; };
90ABB9A62C74EA8A000D9CC7 /* SettingsPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90ABB9A52C74EA8A000D9CC7 /* SettingsPreference.swift */; };
90ABB9A82C74EAB1000D9CC7 /* SettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90ABB9A72C74EAB1000D9CC7 /* SettingsManager.swift */; };
90B6AB312D1359ED00DFF1D9 /* PipecatClientIOSDaily in Frameworks */ = {isa = PBXBuildFile; productRef = 90B6AB302D1359ED00DFF1D9 /* PipecatClientIOSDaily */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
90031FB42C616EE900408370 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 90031F9B2C616EE700408370 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 90031FA22C616EE700408370;
remoteInfo = SimpleChatbot;
};
90031FBE2C616EE900408370 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 90031F9B2C616EE700408370 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 90031FA22C616EE700408370;
remoteInfo = SimpleChatbot;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
90031FA32C616EE700408370 /* SimpleChatbot.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SimpleChatbot.app; sourceTree = BUILT_PRODUCTS_DIR; };
90031FA62C616EE700408370 /* SimpleChatbotApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleChatbotApp.swift; sourceTree = "<group>"; };
90031FA82C616EE700408370 /* PreJoinView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreJoinView.swift; sourceTree = "<group>"; };
90031FAA2C616EE800408370 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
90031FAD2C616EE800408370 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
90031FB32C616EE900408370 /* SimpleChatbotTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SimpleChatbotTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
90031FB72C616EE900408370 /* SimpleChatbotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleChatbotTests.swift; sourceTree = "<group>"; };
90031FBD2C616EE900408370 /* SimpleChatbotUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SimpleChatbotUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
90031FC12C616EE900408370 /* SimpleChatbotUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleChatbotUITests.swift; sourceTree = "<group>"; };
90031FC32C616EE900408370 /* SimpleChatbotUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleChatbotUITestsLaunchTests.swift; sourceTree = "<group>"; };
90031FD62C63FD6A00408370 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
90031FDB2C6D5DD700408370 /* ToastModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastModifier.swift; sourceTree = "<group>"; };
90ABB98D2C735ED6000D9CC7 /* MeetingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingView.swift; sourceTree = "<group>"; };
90ABB98F2C736A8B000D9CC7 /* WaveformView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaveformView.swift; sourceTree = "<group>"; };
90ABB9922C73820D000D9CC7 /* MicrophoneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicrophoneView.swift; sourceTree = "<group>"; };
90ABB9972C738356000D9CC7 /* CustomColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomColors.swift; sourceTree = "<group>"; };
90ABB9992C73A6A9000D9CC7 /* MockCallContainerModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCallContainerModel.swift; sourceTree = "<group>"; };
90ABB99C2C73C2D1000D9CC7 /* CallContainerModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallContainerModel.swift; sourceTree = "<group>"; };
90ABB9A22C74E1CE000D9CC7 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
90ABB9A52C74EA8A000D9CC7 /* SettingsPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsPreference.swift; sourceTree = "<group>"; };
90ABB9A72C74EAB1000D9CC7 /* SettingsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManager.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
90031FA02C616EE700408370 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
90B6AB312D1359ED00DFF1D9 /* PipecatClientIOSDaily in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
90031FB02C616EE900408370 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
90031FBA2C616EE900408370 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
90031F9A2C616EE700408370 = {
isa = PBXGroup;
children = (
90031FA52C616EE700408370 /* SimpleChatbot */,
90031FB62C616EE900408370 /* SimpleChatbotTests */,
90031FC02C616EE900408370 /* SimpleChatbotUITests */,
90031FA42C616EE700408370 /* Products */,
);
sourceTree = "<group>";
};
90031FA42C616EE700408370 /* Products */ = {
isa = PBXGroup;
children = (
90031FA32C616EE700408370 /* SimpleChatbot.app */,
90031FB32C616EE900408370 /* SimpleChatbotTests.xctest */,
90031FBD2C616EE900408370 /* SimpleChatbotUITests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
90031FA52C616EE700408370 /* SimpleChatbot */ = {
isa = PBXGroup;
children = (
90ABB99B2C73C2C5000D9CC7 /* model */,
90031FDD2C6D61E000408370 /* views */,
90031FD62C63FD6A00408370 /* Info.plist */,
90031FA62C616EE700408370 /* SimpleChatbotApp.swift */,
90031FAA2C616EE800408370 /* Assets.xcassets */,
90031FAC2C616EE800408370 /* Preview Content */,
);
path = SimpleChatbot;
sourceTree = "<group>";
};
90031FAC2C616EE800408370 /* Preview Content */ = {
isa = PBXGroup;
children = (
90031FAD2C616EE800408370 /* Preview Assets.xcassets */,
);
path = "Preview Content";
sourceTree = "<group>";
};
90031FB62C616EE900408370 /* SimpleChatbotTests */ = {
isa = PBXGroup;
children = (
90031FB72C616EE900408370 /* SimpleChatbotTests.swift */,
);
path = SimpleChatbotTests;
sourceTree = "<group>";
};
90031FC02C616EE900408370 /* SimpleChatbotUITests */ = {
isa = PBXGroup;
children = (
90031FC12C616EE900408370 /* SimpleChatbotUITests.swift */,
90031FC32C616EE900408370 /* SimpleChatbotUITestsLaunchTests.swift */,
);
path = SimpleChatbotUITests;
sourceTree = "<group>";
};
90031FDD2C6D61E000408370 /* views */ = {
isa = PBXGroup;
children = (
90ABB99E2C73C3A9000D9CC7 /* components */,
90ABB9962C738346000D9CC7 /* extensions */,
90ABB9A42C74EA52000D9CC7 /* settings */,
90031FA82C616EE700408370 /* PreJoinView.swift */,
90ABB98D2C735ED6000D9CC7 /* MeetingView.swift */,
);
path = views;
sourceTree = "<group>";
};
90ABB9962C738346000D9CC7 /* extensions */ = {
isa = PBXGroup;
children = (
90ABB9972C738356000D9CC7 /* CustomColors.swift */,
);
path = extensions;
sourceTree = "<group>";
};
90ABB99B2C73C2C5000D9CC7 /* model */ = {
isa = PBXGroup;
children = (
90ABB9992C73A6A9000D9CC7 /* MockCallContainerModel.swift */,
90ABB99C2C73C2D1000D9CC7 /* CallContainerModel.swift */,
);
path = model;
sourceTree = "<group>";
};
90ABB99E2C73C3A9000D9CC7 /* components */ = {
isa = PBXGroup;
children = (
90ABB98F2C736A8B000D9CC7 /* WaveformView.swift */,
90ABB9922C73820D000D9CC7 /* MicrophoneView.swift */,
90031FDB2C6D5DD700408370 /* ToastModifier.swift */,
);
path = components;
sourceTree = "<group>";
};
90ABB9A42C74EA52000D9CC7 /* settings */ = {
isa = PBXGroup;
children = (
90ABB9A52C74EA8A000D9CC7 /* SettingsPreference.swift */,
90ABB9A72C74EAB1000D9CC7 /* SettingsManager.swift */,
90ABB9A22C74E1CE000D9CC7 /* SettingsView.swift */,
);
path = settings;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
90031FA22C616EE700408370 /* SimpleChatbot */ = {
isa = PBXNativeTarget;
buildConfigurationList = 90031FC72C616EE900408370 /* Build configuration list for PBXNativeTarget "SimpleChatbot" */;
buildPhases = (
90031F9F2C616EE700408370 /* Sources */,
90031FA02C616EE700408370 /* Frameworks */,
90031FA12C616EE700408370 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = SimpleChatbot;
packageProductDependencies = (
90B6AB302D1359ED00DFF1D9 /* PipecatClientIOSDaily */,
);
productName = SimpleChatbot;
productReference = 90031FA32C616EE700408370 /* SimpleChatbot.app */;
productType = "com.apple.product-type.application";
};
90031FB22C616EE900408370 /* SimpleChatbotTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 90031FCA2C616EE900408370 /* Build configuration list for PBXNativeTarget "SimpleChatbotTests" */;
buildPhases = (
90031FAF2C616EE900408370 /* Sources */,
90031FB02C616EE900408370 /* Frameworks */,
90031FB12C616EE900408370 /* Resources */,
);
buildRules = (
);
dependencies = (
90031FB52C616EE900408370 /* PBXTargetDependency */,
);
name = SimpleChatbotTests;
productName = SimpleChatbotTests;
productReference = 90031FB32C616EE900408370 /* SimpleChatbotTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
90031FBC2C616EE900408370 /* SimpleChatbotUITests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 90031FCD2C616EE900408370 /* Build configuration list for PBXNativeTarget "SimpleChatbotUITests" */;
buildPhases = (
90031FB92C616EE900408370 /* Sources */,
90031FBA2C616EE900408370 /* Frameworks */,
90031FBB2C616EE900408370 /* Resources */,
);
buildRules = (
);
dependencies = (
90031FBF2C616EE900408370 /* PBXTargetDependency */,
);
name = SimpleChatbotUITests;
productName = SimpleChatbotUITests;
productReference = 90031FBD2C616EE900408370 /* SimpleChatbotUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
90031F9B2C616EE700408370 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1540;
LastUpgradeCheck = 1540;
TargetAttributes = {
90031FA22C616EE700408370 = {
CreatedOnToolsVersion = 15.4;
};
90031FB22C616EE900408370 = {
CreatedOnToolsVersion = 15.4;
TestTargetID = 90031FA22C616EE700408370;
};
90031FBC2C616EE900408370 = {
CreatedOnToolsVersion = 15.4;
TestTargetID = 90031FA22C616EE700408370;
};
};
};
buildConfigurationList = 90031F9E2C616EE700408370 /* Build configuration list for PBXProject "SimpleChatbot" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 90031F9A2C616EE700408370;
packageReferences = (
90B6AB2F2D1359ED00DFF1D9 /* XCRemoteSwiftPackageReference "pipecat-client-ios-daily" */,
);
productRefGroup = 90031FA42C616EE700408370 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
90031FA22C616EE700408370 /* SimpleChatbot */,
90031FB22C616EE900408370 /* SimpleChatbotTests */,
90031FBC2C616EE900408370 /* SimpleChatbotUITests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
90031FA12C616EE700408370 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
90031FAE2C616EE800408370 /* Preview Assets.xcassets in Resources */,
90031FAB2C616EE800408370 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
90031FB12C616EE900408370 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
90031FBB2C616EE900408370 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
90031F9F2C616EE700408370 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
90ABB99D2C73C2D1000D9CC7 /* CallContainerModel.swift in Sources */,
90ABB9A62C74EA8A000D9CC7 /* SettingsPreference.swift in Sources */,
90ABB99A2C73A6A9000D9CC7 /* MockCallContainerModel.swift in Sources */,
90031FA92C616EE700408370 /* PreJoinView.swift in Sources */,
90ABB9982C738356000D9CC7 /* CustomColors.swift in Sources */,
90ABB98E2C735ED6000D9CC7 /* MeetingView.swift in Sources */,
90ABB9A32C74E1CE000D9CC7 /* SettingsView.swift in Sources */,
90ABB9932C73820D000D9CC7 /* MicrophoneView.swift in Sources */,
90ABB9A82C74EAB1000D9CC7 /* SettingsManager.swift in Sources */,
90031FDC2C6D5DD700408370 /* ToastModifier.swift in Sources */,
90031FA72C616EE700408370 /* SimpleChatbotApp.swift in Sources */,
90ABB9902C736A8B000D9CC7 /* WaveformView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
90031FAF2C616EE900408370 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
90031FB82C616EE900408370 /* SimpleChatbotTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
90031FB92C616EE900408370 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
90031FC42C616EE900408370 /* SimpleChatbotUITestsLaunchTests.swift in Sources */,
90031FC22C616EE900408370 /* SimpleChatbotUITests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
90031FB52C616EE900408370 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 90031FA22C616EE700408370 /* SimpleChatbot */;
targetProxy = 90031FB42C616EE900408370 /* PBXContainerItemProxy */;
};
90031FBF2C616EE900408370 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 90031FA22C616EE700408370 /* SimpleChatbot */;
targetProxy = 90031FBE2C616EE900408370 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
90031FC52C616EE900408370 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.5;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
90031FC62C616EE900408370 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.5;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
90031FC82C616EE900408370 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"SimpleChatbot/Preview Content\"";
DEVELOPMENT_TEAM = EEBGKV9N3N;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SimpleChatbot/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = co.daily.SimpleChatbot;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
90031FC92C616EE900408370 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"SimpleChatbot/Preview Content\"";
DEVELOPMENT_TEAM = EEBGKV9N3N;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SimpleChatbot/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = co.daily.SimpleChatbot;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
90031FCB2C616EE900408370 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = EEBGKV9N3N;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.5;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = co.daily.SimpleChatbotTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SimpleChatbot.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SimpleChatbot";
};
name = Debug;
};
90031FCC2C616EE900408370 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = EEBGKV9N3N;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.5;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = co.daily.SimpleChatbotTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SimpleChatbot.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SimpleChatbot";
};
name = Release;
};
90031FCE2C616EE900408370 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = EEBGKV9N3N;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = co.daily.SimpleChatbotUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = SimpleChatbot;
};
name = Debug;
};
90031FCF2C616EE900408370 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = EEBGKV9N3N;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = co.daily.SimpleChatbotUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = SimpleChatbot;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
90031F9E2C616EE700408370 /* Build configuration list for PBXProject "SimpleChatbot" */ = {
isa = XCConfigurationList;
buildConfigurations = (
90031FC52C616EE900408370 /* Debug */,
90031FC62C616EE900408370 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
90031FC72C616EE900408370 /* Build configuration list for PBXNativeTarget "SimpleChatbot" */ = {
isa = XCConfigurationList;
buildConfigurations = (
90031FC82C616EE900408370 /* Debug */,
90031FC92C616EE900408370 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
90031FCA2C616EE900408370 /* Build configuration list for PBXNativeTarget "SimpleChatbotTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
90031FCB2C616EE900408370 /* Debug */,
90031FCC2C616EE900408370 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
90031FCD2C616EE900408370 /* Build configuration list for PBXNativeTarget "SimpleChatbotUITests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
90031FCE2C616EE900408370 /* Debug */,
90031FCF2C616EE900408370 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
90B6AB2F2D1359ED00DFF1D9 /* XCRemoteSwiftPackageReference "pipecat-client-ios-daily" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/pipecat-ai/pipecat-client-ios-daily";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.3.1;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
90B6AB302D1359ED00DFF1D9 /* PipecatClientIOSDaily */ = {
isa = XCSwiftPackageProductDependency;
package = 90B6AB2F2D1359ED00DFF1D9 /* XCRemoteSwiftPackageReference "pipecat-client-ios-daily" */;
productName = PipecatClientIOSDaily;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 90031F9B2C616EE700408370 /* Project object */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded</key>
<false/>
</dict>
</plist>

View File

@@ -0,0 +1,33 @@
{
"originHash" : "72945fe9f3d71a3b546d5d2df153345b4dc9e72c00b2fda349508223e1a88501",
"pins" : [
{
"identity" : "daily-client-ios",
"kind" : "remoteSourceControl",
"location" : "https://github.com/daily-co/daily-client-ios.git",
"state" : {
"revision" : "15804ce495780da3ec2d05ab99736315f7bfbd24",
"version" : "0.28.0"
}
},
{
"identity" : "pipecat-client-ios",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pipecat-ai/pipecat-client-ios.git",
"state" : {
"revision" : "d2d9946cf93f1e89290c724caa1631cffa439524",
"version" : "0.3.1"
}
},
{
"identity" : "pipecat-client-ios-daily",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pipecat-ai/pipecat-client-ios-daily",
"state" : {
"revision" : "cec4beef8762604c9a9b5ec760081ea2a0a697bf",
"version" : "0.3.1"
}
}
],
"version" : 3
}

View File

@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1540"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "90031FA22C616EE700408370"
BuildableName = "SimpleChatbot.app"
BlueprintName = "SimpleChatbot"
ReferencedContainer = "container:SimpleChatbot.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "90031FA22C616EE700408370"
BuildableName = "SimpleChatbot.app"
BlueprintName = "SimpleChatbot"
ReferencedContainer = "container:SimpleChatbot.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "90031FA22C616EE700408370"
BuildableName = "SimpleChatbot.app"
BlueprintName = "SimpleChatbot"
ReferencedContainer = "container:SimpleChatbot.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "appstore.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "Square Black.svg",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,8 @@
<svg width="450" height="450" viewBox="0 0 450 450" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="450" height="450" fill="white"/>
<path d="M104.772 129.77C109.448 128.01 114.725 129.331 118.02 133.086L160.936 182H289.064L331.98 133.086C335.275 129.331 340.552 128.01 345.228 129.77C349.904 131.531 353 136.004 353 141V249H391V273H329V172.873L303.52 201.915C301.242 204.511 297.955 206 294.5 206H155.5C152.045 206 148.758 204.511 146.48 201.915L121 172.873V273H59V249H97V141C97 136.004 100.096 131.531 104.772 129.77Z" fill="black"/>
<path d="M329 297H391V321H329V297Z" fill="black"/>
<path d="M59 297H121V321H59V297Z" fill="black"/>
<path d="M187 257C187 265.837 179.837 273 171 273C162.163 273 155 265.837 155 257C155 248.164 162.163 241 171 241C179.837 241 187 248.164 187 257Z" fill="black"/>
<path d="M295 257C295 265.837 287.837 273 279 273C270.163 273 263 265.837 263 257C263 248.164 270.163 241 279 241C287.837 241 295 248.164 295 257Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 982 B

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>UIBackgroundModes</key>
<array>
<string>voip</string>
</array>
<key>NSCameraUsageDescription</key>
<string>Camera is necessary for transmitting video in a call</string>
<key>NSMicrophoneUsageDescription</key>
<string>Microphone is necessary for transmitting audio in a call</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,18 @@
import SwiftUI
@main
struct SimpleChatbotApp: App {
@StateObject var callContainerModel = CallContainerModel()
var body: some Scene {
WindowGroup {
if (!callContainerModel.isInCall) {
PreJoinView().environmentObject(callContainerModel)
} else {
MeetingView().environmentObject(callContainerModel)
}
}
}
}

View File

@@ -0,0 +1,199 @@
import SwiftUI
import RTVIClientIOSDaily
import RTVIClientIOS
class CallContainerModel: ObservableObject {
@Published var voiceClientStatus: String = TransportState.disconnected.description
@Published var isInCall: Bool = false
@Published var isBotReady: Bool = false
@Published var timerCount = 0
@Published var isMicEnabled: Bool = false
@Published var toastMessage: String? = nil
@Published var showToast: Bool = false
@Published
var remoteAudioLevel: Float = 0
@Published
var localAudioLevel: Float = 0
private var meetingTimer: Timer?
var rtviClientIOS: RTVIClient?
init() {
// Changing the log level
RTVIClientIOS.setLogLevel(.warn)
}
@MainActor
func connect(backendURL: String) {
let baseUrl = backendURL.trimmingCharacters(in: .whitespacesAndNewlines)
if(baseUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty){
self.showError(message: "Need to fill the backendURL. For more info visit: https://bots.daily.co")
return
}
let currentSettings = SettingsManager.getSettings()
let rtviClientOptions = RTVIClientOptions.init(
enableMic: currentSettings.enableMic,
enableCam: false,
params: RTVIClientParams(
baseUrl: baseUrl,
endpoints: RTVIURLEndpoints(connect: "/connect")
)
)
self.rtviClientIOS = RTVIClient.init(
transport: DailyTransport.init(options: rtviClientOptions),
options: rtviClientOptions
)
self.rtviClientIOS?.delegate = self
self.rtviClientIOS?.start() { result in
if case .failure(let error) = result {
self.showError(message: error.localizedDescription)
self.rtviClientIOS = nil
}
}
// Selecting the mic based on the preferences
if let selectedMic = currentSettings.selectedMic {
self.rtviClientIOS?.updateMic(micId: MediaDeviceId(id:selectedMic), completion: nil)
}
self.saveCredentials(backendURL: baseUrl)
}
@MainActor
func disconnect() {
self.rtviClientIOS?.disconnect(completion: nil)
}
func showError(message: String) {
self.toastMessage = message
self.showToast = true
// Hide the toast after 5 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
self.showToast = false
self.toastMessage = nil
}
}
@MainActor
func toggleMicInput() {
self.rtviClientIOS?.enableMic(enable: !self.isMicEnabled) { result in
switch result {
case .success():
self.isMicEnabled = self.rtviClientIOS?.isMicEnabled ?? false
case .failure(let error):
self.showError(message: error.localizedDescription)
}
}
}
private func startTimer(withExpirationTime expirationTime: Int) {
let currentTime = Int(Date().timeIntervalSince1970)
self.timerCount = expirationTime - currentTime
self.meetingTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
DispatchQueue.main.async {
self.timerCount -= 1
}
}
}
private func stopTimer() {
self.meetingTimer?.invalidate()
self.meetingTimer = nil
self.timerCount = 0
}
func saveCredentials(backendURL: String) {
var currentSettings = SettingsManager.getSettings()
currentSettings.backendURL = backendURL
// Saving the settings
SettingsManager.updateSettings(settings: currentSettings)
}
}
extension CallContainerModel:RTVIClientDelegate, LLMHelperDelegate {
private func handleEvent(eventName: String, eventValue: Any? = nil) {
if let value = eventValue {
print("RTVI Demo, received event:\(eventName), value:\(value)")
} else {
print("RTVI Demo, received event: \(eventName)")
}
}
func onTransportStateChanged(state: TransportState) {
Task { @MainActor in
self.handleEvent(eventName: "onTransportStateChanged", eventValue: state)
self.voiceClientStatus = state.description
self.isInCall = ( state == .connecting || state == .connected || state == .ready || state == .authenticating )
}
}
func onBotReady(botReadyData: BotReadyData) {
Task { @MainActor in
self.handleEvent(eventName: "onBotReady.")
self.isBotReady = true
if let expirationTime = self.rtviClientIOS?.expiry() {
self.startTimer(withExpirationTime: expirationTime)
}
}
}
func onConnected() {
Task { @MainActor in
self.isMicEnabled = self.rtviClientIOS?.isMicEnabled ?? false
}
}
func onDisconnected() {
Task { @MainActor in
self.stopTimer()
self.isBotReady = false
}
}
func onRemoteAudioLevel(level: Float, participant: Participant) {
Task { @MainActor in
self.remoteAudioLevel = level
}
}
func onUserAudioLevel(level: Float) {
Task { @MainActor in
self.localAudioLevel = level
}
}
func onUserTranscript(data: Transcript) {
Task { @MainActor in
if (data.final ?? false) {
self.handleEvent(eventName: "onUserTranscript", eventValue: data.text)
}
}
}
func onBotTranscript(data: String) {
Task { @MainActor in
self.handleEvent(eventName: "onBotTranscript", eventValue: data)
}
}
func onError(message: String) {
Task { @MainActor in
self.handleEvent(eventName: "onError", eventValue: message)
self.showError(message: message)
}
}
func onTracksUpdated(tracks: Tracks) {
Task { @MainActor in
self.handleEvent(eventName: "onTracksUpdated", eventValue: tracks)
}
}
}

View File

@@ -0,0 +1,35 @@
import SwiftUI
import RTVIClientIOS
class MockCallContainerModel: CallContainerModel {
override init() {
}
override func connect(backendURL: String) {
print("connect")
}
override func disconnect() {
print("disconnect")
}
override func showError(message: String) {
self.toastMessage = message
self.showToast = true
// Hide the toast after 5 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
self.showToast = false
self.toastMessage = nil
}
}
func startAudioLevelSimulation() {
// Simulate audio level changes
Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
let newLevel = Float.random(in: 0...1)
self.remoteAudioLevel = newLevel
self.localAudioLevel = newLevel
}
}
}

View File

@@ -0,0 +1,107 @@
import SwiftUI
struct MeetingView: View {
@State private var showingSettings = false
@EnvironmentObject private var model: CallContainerModel
var body: some View {
VStack {
// Header Toolbar
HStack {
Image("dailyBot")
.resizable()
.frame(width: 48, height: 48)
Spacer()
HStack {
Image(systemName: "stopwatch")
.resizable()
.frame(width: 24, height: 24)
Text(timerString(from: self.model.timerCount))
.font(.headline)
}.padding()
.background(Color.timer)
.cornerRadius(12)
}
.padding()
// Main Panel
VStack {
VStack {
WaveformView(audioLevel: model.remoteAudioLevel, isBotReady: model.isBotReady, voiceClientStatus: model.voiceClientStatus)
}
.frame(maxHeight: .infinity)
VStack {
HStack {
MicrophoneView(audioLevel: model.localAudioLevel, isMuted: !self.model.isMicEnabled)
.frame(width: 160, height: 160)
.onTapGesture {
self.model.toggleMicInput()
}
}
}
.frame(height: 120)
}
.frame(maxHeight: .infinity)
.padding()
// Bottom Panel
VStack {
HStack {
Button(action: {
self.showingSettings = true
}) {
HStack {
Image(systemName: "gearshape")
.resizable()
.frame(width: 24, height: 24)
Text("Settings")
}
.frame(maxWidth: .infinity)
.padding()
.sheet(isPresented: $showingSettings) {
SettingsView(showingSettings: $showingSettings).environmentObject(self.model)
}
}
.border(Color.buttonsBorder, width: 1)
.cornerRadius(12)
}
.foregroundColor(.black)
.padding([.top, .horizontal])
Button(action: {
self.model.disconnect()
}) {
HStack {
Image(systemName: "rectangle.portrait.and.arrow.right")
.resizable()
.frame(width: 24, height: 24)
Text("End")
}
.frame(maxWidth: .infinity)
.padding()
}
.foregroundColor(.white)
.background(Color.black)
.cornerRadius(12)
.padding([.bottom, .horizontal])
}
}
.background(Color.backgroundApp)
.toast(message: model.toastMessage, isShowing: model.showToast)
}
func timerString(from count: Int) -> String {
let hours = count / 3600
let minutes = (count % 3600) / 60
let seconds = count % 60
return String(format: "%02d:%02d:%02d", hours, minutes, seconds)
}
}
#Preview {
let mockModel = MockCallContainerModel()
let result = MeetingView().environmentObject(mockModel as CallContainerModel)
mockModel.startAudioLevelSimulation()
return result
}

View File

@@ -0,0 +1,42 @@
import SwiftUI
struct PreJoinView: View {
@State var backendURL: String
@EnvironmentObject private var model: CallContainerModel
init() {
let currentSettings = SettingsManager.getSettings()
self.backendURL = currentSettings.backendURL
}
var body: some View {
VStack(spacing: 20) {
Image("pipecat")
.resizable()
.frame(width: 80, height: 80)
Text("Pipecat Client iOS.")
.font(.headline)
TextField("Server URL", text: $backendURL)
.textFieldStyle(RoundedBorderTextFieldStyle())
.frame(maxWidth: .infinity)
.padding([.bottom, .horizontal])
Button("Connect") {
self.model.connect(backendURL: self.backendURL)
}
.padding()
.background(Color.black)
.foregroundColor(.white)
.cornerRadius(8)
}
.padding()
.frame(maxHeight: .infinity)
.background(Color.backgroundApp)
.toast(message: model.toastMessage, isShowing: model.showToast)
}
}
#Preview {
PreJoinView().environmentObject(MockCallContainerModel() as CallContainerModel)
}

View File

@@ -0,0 +1,44 @@
import SwiftUI
struct MicrophoneView: View {
var audioLevel: Float // Current audio level
var isMuted: Bool // Muted state
var body: some View {
GeometryReader { geometry in
let width = geometry.size.width
let circleSize = width * 0.9
let innerCircleSize = width * 0.82
let audioCircleSize = CGFloat(audioLevel) * (width * 0.95)
ZStack {
Circle()
.stroke(Color.gray, lineWidth: 1)
.frame(width: circleSize)
Circle()
.fill(isMuted ? Color.disabledMic : Color.backgroundCircle)
.frame(width: innerCircleSize)
if !isMuted {
Circle()
.fill(Color.micVolume)
.opacity(0.5)
.frame(width: audioCircleSize)
.animation(.easeInOut(duration: 0.2), value: audioLevel)
}
Image(systemName: isMuted ? "mic.slash.fill" : "mic.fill")
.resizable()
.scaledToFit()
.frame(width: width * 0.2)
.foregroundColor(.white)
}
.frame(maxWidth: .infinity, maxHeight: .infinity) // Ensures the ZStack is centered
}
}
}
#Preview {
MicrophoneView(audioLevel: 1, isMuted: false)
}

View File

@@ -0,0 +1,31 @@
import SwiftUI
struct ToastModifier: ViewModifier {
var message: String?
var isShowing: Bool
func body(content: Content) -> some View {
ZStack {
content
if isShowing, let message = message {
VStack {
Text(message)
.padding()
.background(Color.black.opacity(0.7))
.foregroundColor(.white)
.cornerRadius(8)
.transition(.slide)
.padding(.top, 50)
Spacer()
}
.animation(.easeInOut(duration: 0.5), value: isShowing)
}
}
}
}
extension View {
func toast(message: String?, isShowing: Bool) -> some View {
self.modifier(ToastModifier(message: message, isShowing: isShowing))
}
}

View File

@@ -0,0 +1,93 @@
import SwiftUI
struct WaveformView: View {
var audioLevel: Float
var isBotReady: Bool
var voiceClientStatus: String
@State
private var audioLevels: [Float] = Array(repeating: 0, count: 5)
private let dotCount = 5
var body: some View {
GeometryReader { geometry in
VStack {
Spacer()
HStack {
Spacer()
ZStack {
// Outer gray border
Circle()
.stroke(Color.gray, lineWidth: 1)
.frame(width: geometry.size.width * 0.9, height: geometry.size.width * 0.9)
// Gray middle
Circle()
.fill(isBotReady ? Color.backgroundCircle : Color.backgroundCircleNotConnected)
.frame(width: geometry.size.width * 0.82, height: geometry.size.width * 0.82)
if isBotReady {
if audioLevel > 0 {
// Waveform bars inside the circle
HStack(spacing: 10) {
ForEach(0..<dotCount, id: \.self) { index in
Rectangle()
.fill(Color.white)
.frame(height: CGFloat(audioLevels[index]) * (geometry.size.height))
.cornerRadius(12)
.animation(.easeInOut(duration: 0.2), value: audioLevels[index])
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 5)
}
.frame(width: geometry.size.width * 0.5, height: geometry.size.width * 0.5)
.mask(Circle().frame(width: geometry.size.width * 0.82, height: geometry.size.width * 0.82))
} else {
// Dots inside the circle
HStack(spacing: 10) {
ForEach(0..<dotCount, id: \.self) { _ in
Circle()
.fill(Color.white)
.frame(maxWidth: .infinity)
}
.frame(maxWidth: .infinity)
}
.frame(width: geometry.size.width * 0.5, height: geometry.size.height * 0.5)
}
} else {
// Gray circle with loading icon when not connected
VStack {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(2) // Adjust size of the loading spinner
.padding()
Text(voiceClientStatus)
.foregroundColor(.white)
.font(.headline)
}
}
}
Spacer()
}
Spacer()
}
}
.onChange(of: audioLevel) { oldLevel, newLevel in
// The audio level that we receive from the bot is usually too low
// so just increasing it so we can see a better graph but
// making sure that it is not higher than the maximum 1
var audioLevel = audioLevel + 0.4
if(audioLevel > 1) {
audioLevel = 1
}
// Update the array and shift values
audioLevels.removeFirst()
audioLevels.append(newLevel)
}
}
}
#Preview {
WaveformView(audioLevel: 0, isBotReady: false, voiceClientStatus: "idle")
}

View File

@@ -0,0 +1,27 @@
import SwiftUI
public extension Color {
static let backgroundCircle = Color(hex: "#374151")
static let backgroundCircleNotConnected = Color(hex: "#D1D5DB")
static let backgroundApp = Color(hex: "#F9FAFB")
static let buttonsBorder = Color(hex: "#E5E7EB")
static let micVolume = Color(hex: "#86EFAC")
static let timer = Color(hex: "#E5E7EB")
static let disabledMic = Color(hex: "#ee6b6e")
static let disabledVision = Color(hex: "#BBF7D0")
init(hex: String) {
let scanner = Scanner(string: hex)
_ = scanner.scanString("#")
var rgb: UInt64 = 0
scanner.scanHexInt64(&rgb)
let red = Double((rgb >> 16) & 0xFF) / 255.0
let green = Double((rgb >> 8) & 0xFF) / 255.0
let blue = Double(rgb & 0xFF) / 255.0
self.init(red: red, green: green, blue: blue)
}
}

View File

@@ -0,0 +1,21 @@
import Foundation
class SettingsManager {
private static let preferencesKey = "settingsPreference"
static func getSettings() -> SettingsPreference {
if let data = UserDefaults.standard.data(forKey: preferencesKey),
let settings = try? JSONDecoder().decode(SettingsPreference.self, from: data) {
return settings
} else {
// default values in case we don't have any settings
return SettingsPreference(enableMic: true, backendURL: "http://YOUR_IP:7860")
}
}
static func updateSettings(settings: SettingsPreference) {
if let data = try? JSONEncoder().encode(settings) {
UserDefaults.standard.set(data, forKey: preferencesKey)
}
}
}

View File

@@ -0,0 +1,8 @@
import Foundation
struct SettingsPreference: Codable {
var selectedMic: String?
var enableMic: Bool
var backendURL: String
}

View File

@@ -0,0 +1,87 @@
import SwiftUI
import RTVIClientIOS
struct SettingsView: View {
@EnvironmentObject private var model: CallContainerModel
@Binding var showingSettings: Bool
@State private var selectedMic: MediaDeviceId? = nil
@State private var isMicEnabled: Bool = true
@State private var backendURL: String = ""
var body: some View {
let microphones = self.model.rtviClientIOS?.getAllMics() ?? []
NavigationView {
Form {
Section(header: Text("Audio Settings")) {
List(microphones, id: \.self.id.id) { mic in
Button(action: {
self.selectMic(mic.id)
}) {
HStack {
Text(mic.name)
Spacer()
if mic.id == self.selectedMic {
Image(systemName: "checkmark")
}
}
}
}
}
Section(header: Text("Start options")) {
Toggle("Enable Microphone", isOn: $isMicEnabled)
}
Section(header: Text("Server")) {
TextField("Backend URL", text: $backendURL)
.keyboardType(.URL)
}
}
.navigationTitle("Settings")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close") {
self.saveSettings()
self.showingSettings = false
}
}
}
.onAppear {
self.loadSettings()
}
}
}
private func selectMic(_ mic: MediaDeviceId) {
self.selectedMic = mic
self.model.rtviClientIOS?.updateMic(micId: mic, completion: nil)
}
private func saveSettings() {
let newSettings = SettingsPreference(
selectedMic: selectedMic?.id,
enableMic: isMicEnabled,
backendURL: backendURL
)
SettingsManager.updateSettings(settings: newSettings)
}
private func loadSettings() {
let savedSettings = SettingsManager.getSettings()
if let selectedMic = savedSettings.selectedMic {
self.selectedMic = MediaDeviceId(id: selectedMic)
} else {
self.selectedMic = nil
}
self.isMicEnabled = savedSettings.enableMic
self.backendURL = savedSettings.backendURL
}
}
#Preview {
let mockModel = MockCallContainerModel()
let result = SettingsView(showingSettings: .constant(true)).environmentObject(mockModel as CallContainerModel)
mockModel.startAudioLevelSimulation()
return result
}

View File

@@ -0,0 +1,29 @@
import XCTest
@testable import SimpleChatbot
final class SimpleChatbotTests: XCTestCase {
override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.
}
override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}
func testExample() throws {
// This is an example of a functional test case.
// Use XCTAssert and related functions to verify your tests produce the correct results.
// Any test you write for XCTest can be annotated as throws and async.
// Mark your test throws to produce an unexpected failure when your test encounters an uncaught error.
// Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards.
}
func testPerformanceExample() throws {
// This is an example of a performance test case.
self.measure {
// Put the code you want to measure the time of here.
}
}
}

View File

@@ -0,0 +1,34 @@
import XCTest
final class SimpleChatbotUITests: XCTestCase {
override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.
// In UI tests it is usually best to stop immediately when a failure occurs.
continueAfterFailure = false
// In UI tests its important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
}
override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}
func testExample() throws {
// UI tests must launch the application that they test.
let app = XCUIApplication()
app.launch()
// Use XCTAssert and related functions to verify your tests produce the correct results.
}
func testLaunchPerformance() throws {
if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) {
// This measures how long it takes to launch your application.
measure(metrics: [XCTApplicationLaunchMetric()]) {
XCUIApplication().launch()
}
}
}
}

View File

@@ -0,0 +1,25 @@
import XCTest
final class SimpleChatbotUITestsLaunchTests: XCTestCase {
override class var runsForEachTargetApplicationUIConfiguration: Bool {
true
}
override func setUpWithError() throws {
continueAfterFailure = false
}
func testLaunch() throws {
let app = XCUIApplication()
app.launch()
// Insert steps here to perform after app launch but before taking a screenshot,
// such as logging into a test account or navigating somewhere in the app
let attachment = XCTAttachment(screenshot: app.screenshot())
attachment.name = "Launch Screen"
attachment.lifetime = .keepAlways
add(attachment)
}
}

View File

@@ -0,0 +1,27 @@
# JavaScript Implementation
Basic implementation using the [Pipecat JavaScript SDK](https://docs.pipecat.ai/client/js/introduction).
## Setup
1. Run the bot server. See the [server README](../../README).
2. Navigate to the `examples/javascript` directory:
```bash
cd examples/javascript
```
3. Install dependencies:
```bash
npm install
```
4. Run the client app:
```
npm run dev
```
5. Visit http://localhost:5173 in your browser.

View File

@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Chatbot</title>
</head>
<body>
<div class="container">
<div class="status-bar">
<div class="status">
Status: <span id="connection-status">Disconnected</span>
</div>
<div class="controls">
<button id="connect-btn">Connect</button>
<button id="disconnect-btn" disabled>Disconnect</button>
</div>
</div>
<div class="main-content">
<div class="bot-container">
<div id="bot-video-container">
</div>
<audio id="bot-audio" autoplay></audio>
</div>
</div>
<div class="debug-panel">
<h3>Debug Info</h3>
<div id="debug-log"></div>
</div>
</div>
<script type="module" src="/src/app.js"></script>
<link rel="stylesheet" href="/src/style.css">
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
{
"name": "client",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"@daily-co/realtime-ai-daily": "^0.2.1",
"realtime-ai": "^0.2.1"
},
"devDependencies": {
"vite": "^6.0.2"
}
}

View File

@@ -0,0 +1,314 @@
/**
* Copyright (c) 2025, Daily
*
* SPDX-License-Identifier: BSD 2-Clause License
*/
/**
* RTVI Client Implementation
*
* This client connects to an RTVI-compatible bot server using WebRTC (via Daily).
* It handles audio/video streaming and manages the connection lifecycle.
*
* Requirements:
* - A running RTVI bot server (defaults to http://localhost:7860)
* - The server must implement the /connect endpoint that returns Daily.co room credentials
* - Browser with WebRTC support
*/
import { RTVIClient, RTVIEvent } from 'realtime-ai';
import { DailyTransport } from '@daily-co/realtime-ai-daily';
/**
* ChatbotClient handles the connection and media management for a real-time
* voice and video interaction with an AI bot.
*/
class ChatbotClient {
constructor() {
// Initialize client state
this.rtviClient = null;
this.setupDOMElements();
this.setupEventListeners();
}
/**
* Set up references to DOM elements and create necessary media elements
*/
setupDOMElements() {
// Get references to UI control elements
this.connectBtn = document.getElementById('connect-btn');
this.disconnectBtn = document.getElementById('disconnect-btn');
this.statusSpan = document.getElementById('connection-status');
this.debugLog = document.getElementById('debug-log');
this.botVideoContainer = document.getElementById('bot-video-container');
// Create an audio element for bot's voice output
this.botAudio = document.createElement('audio');
this.botAudio.autoplay = true;
this.botAudio.playsInline = true;
document.body.appendChild(this.botAudio);
}
/**
* Set up event listeners for connect/disconnect buttons
*/
setupEventListeners() {
this.connectBtn.addEventListener('click', () => this.connect());
this.disconnectBtn.addEventListener('click', () => this.disconnect());
}
/**
* Add a timestamped message to the debug log
*/
log(message) {
const entry = document.createElement('div');
entry.textContent = `${new Date().toISOString()} - ${message}`;
// Add styling based on message type
if (message.startsWith('User: ')) {
entry.style.color = '#2196F3'; // blue for user
} else if (message.startsWith('Bot: ')) {
entry.style.color = '#4CAF50'; // green for bot
}
this.debugLog.appendChild(entry);
this.debugLog.scrollTop = this.debugLog.scrollHeight;
console.log(message);
}
/**
* Update the connection status display
*/
updateStatus(status) {
this.statusSpan.textContent = status;
this.log(`Status: ${status}`);
}
/**
* Check for available media tracks and set them up if present
* This is called when the bot is ready or when the transport state changes to ready
*/
setupMediaTracks() {
if (!this.rtviClient) return;
// Get current tracks from the client
const tracks = this.rtviClient.tracks();
// Set up any available bot tracks
if (tracks.bot?.audio) {
this.setupAudioTrack(tracks.bot.audio);
}
if (tracks.bot?.video) {
this.setupVideoTrack(tracks.bot.video);
}
}
/**
* Set up listeners for track events (start/stop)
* This handles new tracks being added during the session
*/
setupTrackListeners() {
if (!this.rtviClient) return;
// Listen for new tracks starting
this.rtviClient.on(RTVIEvent.TrackStarted, (track, participant) => {
// Only handle non-local (bot) tracks
if (!participant?.local) {
if (track.kind === 'audio') {
this.setupAudioTrack(track);
} else if (track.kind === 'video') {
this.setupVideoTrack(track);
}
}
});
// Listen for tracks stopping
this.rtviClient.on(RTVIEvent.TrackStopped, (track, participant) => {
this.log(
`Track stopped event: ${track.kind} from ${
participant?.name || 'unknown'
}`
);
});
}
/**
* Set up an audio track for playback
* Handles both initial setup and track updates
*/
setupAudioTrack(track) {
this.log('Setting up audio track');
// Check if we're already playing this track
if (this.botAudio.srcObject) {
const oldTrack = this.botAudio.srcObject.getAudioTracks()[0];
if (oldTrack?.id === track.id) return;
}
// Create a new MediaStream with the track and set it as the audio source
this.botAudio.srcObject = new MediaStream([track]);
}
/**
* Set up a video track for display
* Handles both initial setup and track updates
*/
setupVideoTrack(track) {
this.log('Setting up video track');
const videoEl = document.createElement('video');
videoEl.autoplay = true;
videoEl.playsInline = true;
videoEl.muted = true;
videoEl.style.width = '100%';
videoEl.style.height = '100%';
videoEl.style.objectFit = 'cover';
// Check if we're already displaying this track
if (this.botVideoContainer.querySelector('video')?.srcObject) {
const oldTrack = this.botVideoContainer
.querySelector('video')
.srcObject.getVideoTracks()[0];
if (oldTrack?.id === track.id) return;
}
// Create a new MediaStream with the track and set it as the video source
videoEl.srcObject = new MediaStream([track]);
this.botVideoContainer.innerHTML = '';
this.botVideoContainer.appendChild(videoEl);
}
/**
* Initialize and connect to the bot
* This sets up the RTVI client, initializes devices, and establishes the connection
*/
async connect() {
try {
// Create a new Daily transport for WebRTC communication
const transport = new DailyTransport();
// Initialize the RTVI client with our configuration
this.rtviClient = new RTVIClient({
transport,
params: {
// The baseURL and endpoint of your bot server that the client will connect to
baseUrl: 'http://localhost:7860',
endpoints: {
connect: '/connect',
},
},
enableMic: true, // Enable microphone for user input
enableCam: false,
callbacks: {
// Handle connection state changes
onConnected: () => {
this.updateStatus('Connected');
this.connectBtn.disabled = true;
this.disconnectBtn.disabled = false;
this.log('Client connected');
},
onDisconnected: () => {
this.updateStatus('Disconnected');
this.connectBtn.disabled = false;
this.disconnectBtn.disabled = true;
this.log('Client disconnected');
},
// Handle transport state changes
onTransportStateChanged: (state) => {
this.updateStatus(`Transport: ${state}`);
this.log(`Transport state changed: ${state}`);
if (state === 'ready') {
this.setupMediaTracks();
}
},
// Handle bot connection events
onBotConnected: (participant) => {
this.log(`Bot connected: ${JSON.stringify(participant)}`);
},
onBotDisconnected: (participant) => {
this.log(`Bot disconnected: ${JSON.stringify(participant)}`);
},
onBotReady: (data) => {
this.log(`Bot ready: ${JSON.stringify(data)}`);
this.setupMediaTracks();
},
// Transcript events
onUserTranscript: (data) => {
// Only log final transcripts
if (data.final) {
this.log(`User: ${data.text}`);
}
},
onBotTranscript: (data) => {
this.log(`Bot: ${data.text}`);
},
// Error handling
onMessageError: (error) => {
console.log('Message error:', error);
},
onError: (error) => {
console.log('Error:', error);
},
},
});
// Set up listeners for media track events
this.setupTrackListeners();
// Initialize audio/video devices
this.log('Initializing devices...');
await this.rtviClient.initDevices();
// Connect to the bot
this.log('Connecting to bot...');
await this.rtviClient.connect();
this.log('Connection complete');
} catch (error) {
// Handle any errors during connection
this.log(`Error connecting: ${error.message}`);
this.log(`Error stack: ${error.stack}`);
this.updateStatus('Error');
// Clean up if there's an error
if (this.rtviClient) {
try {
await this.rtviClient.disconnect();
} catch (disconnectError) {
this.log(`Error during disconnect: ${disconnectError.message}`);
}
}
}
}
/**
* Disconnect from the bot and clean up media resources
*/
async disconnect() {
if (this.rtviClient) {
try {
// Disconnect the RTVI client
await this.rtviClient.disconnect();
this.rtviClient = null;
// Clean up audio
if (this.botAudio.srcObject) {
this.botAudio.srcObject.getTracks().forEach((track) => track.stop());
this.botAudio.srcObject = null;
}
// Clean up video
if (this.botVideoContainer.querySelector('video')?.srcObject) {
const video = this.botVideoContainer.querySelector('video');
video.srcObject.getTracks().forEach((track) => track.stop());
video.srcObject = null;
}
this.botVideoContainer.innerHTML = '';
} catch (error) {
this.log(`Error disconnecting: ${error.message}`);
}
}
}
}
// Initialize the client when the page loads
window.addEventListener('DOMContentLoaded', () => {
new ChatbotClient();
});

View File

@@ -0,0 +1,98 @@
body {
margin: 0;
padding: 20px;
font-family: Arial, sans-serif;
background-color: #f0f0f0;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.status-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
background-color: #fff;
border-radius: 8px;
margin-bottom: 20px;
}
.controls button {
padding: 8px 16px;
margin-left: 10px;
border: none;
border-radius: 4px;
cursor: pointer;
}
#connect-btn {
background-color: #4caf50;
color: white;
}
#disconnect-btn {
background-color: #f44336;
color: white;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.main-content {
background-color: #fff;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.bot-container {
display: flex;
flex-direction: column;
align-items: center;
}
#bot-video-container {
width: 640px;
height: 360px;
background-color: #e0e0e0;
border-radius: 8px;
margin: 20px auto;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
#bot-video-container video {
width: 100%;
height: 100%;
object-fit: cover;
}
.debug-panel {
background-color: #fff;
border-radius: 8px;
padding: 20px;
}
.debug-panel h3 {
margin: 0 0 10px 0;
font-size: 16px;
font-weight: bold;
}
#debug-log {
height: 200px;
overflow-y: auto;
background-color: #f8f8f8;
padding: 10px;
border-radius: 4px;
font-family: monospace;
font-size: 12px;
line-height: 1.4;
}

View File

@@ -0,0 +1,15 @@
# Daily Prebuilt Connection
The simplest way to connect to the chatbot using Daily's Prebuilt UI.
1. Start the bot server
```bash
python server/server.py
```
2. Visit http://localhost:7860
3. Allow microphone access when prompted
4. Start talking with the bot

View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,27 @@
# React Implementation
Basic implementation using the [Pipecat React SDK](https://docs.pipecat.ai/client/react/introduction).
## Setup
1. Run the bot server; see [README](../../README).
2. Navigate to the `examples/react` directory:
```bash
cd examples/react
```
3. Install dependencies:
```bash
npm install
```
4. Run the client app:
```
npm run dev
```
5. Visit http://localhost:5173 in your browser.

View File

@@ -0,0 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)

View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pipecat React Client</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,32 @@
{
"name": "react",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@daily-co/realtime-ai-daily": "^0.2.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"realtime-ai": "^0.2.1",
"realtime-ai-react": "^0.2.1"
},
"devDependencies": {
"@eslint/js": "^9.15.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.15.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.14",
"globals": "^15.12.0",
"typescript": "~5.6.2",
"typescript-eslint": "^8.15.0",
"vite": "^6.0.1"
}
}

Some files were not shown because too many files have changed in this diff Show More