├── app
├── favicon.ico
├── fonts
│ ├── ABCRepro-Black.otf
│ ├── ABCRepro-Black.woff
│ ├── ABCRepro-Bold.otf
│ ├── ABCRepro-Bold.woff
│ ├── ABCRepro-Bold.woff2
│ ├── ABCRepro-Black.woff2
│ ├── ABCRepro-Regular.otf
│ ├── ABCRepro-Regular.woff
│ ├── ABCReproMono-Bold.otf
│ ├── ABCRepro-Regular.woff2
│ ├── ABCReproMono-Bold.woff
│ ├── ABCReproMono-Bold.woff2
│ ├── ABCReproMono-Regular.otf
│ ├── ABCReproMono-Regular.woff
│ ├── ABCReproMono-Regular.woff2
│ └── fonts.ts
├── components
│ ├── VideoBox.tsx
│ ├── DottedFace.tsx
│ ├── Logo.tsx
│ └── Navbar.tsx
├── utils
│ └── TailwindMergeAndClsx.ts
├── layout.tsx
├── globals.css
├── page.tsx
└── AvatarInteraction.tsx
├── media
├── image.png
├── image-2.png
├── image-3.png
├── image-4.png
├── image-5.png
├── image-6.png
├── dottedface.gif
├── IconSparkleLoader.tsx
├── sparkle.svg
├── github-mark-white.svg
└── SimliLogoV2.svg
├── public
├── image-4.png
├── characters
│ ├── Marie.jpg
│ └── einstein.jpg
├── vercel.svg
└── next.svg
├── next.config.mjs
├── postcss.config.mjs
├── next-env.d.ts
├── tsconfig.json
├── package.json
├── tailwind.config.ts
├── utils
└── validateApiKeys.ts
├── README.md
├── .gitignore
└── server.ts
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simliai/create-simli-app/HEAD/app/favicon.ico
--------------------------------------------------------------------------------
/media/image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simliai/create-simli-app/HEAD/media/image.png
--------------------------------------------------------------------------------
/media/image-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simliai/create-simli-app/HEAD/media/image-2.png
--------------------------------------------------------------------------------
/media/image-3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simliai/create-simli-app/HEAD/media/image-3.png
--------------------------------------------------------------------------------
/media/image-4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simliai/create-simli-app/HEAD/media/image-4.png
--------------------------------------------------------------------------------
/media/image-5.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simliai/create-simli-app/HEAD/media/image-5.png
--------------------------------------------------------------------------------
/media/image-6.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simliai/create-simli-app/HEAD/media/image-6.png
--------------------------------------------------------------------------------
/public/image-4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simliai/create-simli-app/HEAD/public/image-4.png
--------------------------------------------------------------------------------
/media/dottedface.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simliai/create-simli-app/HEAD/media/dottedface.gif
--------------------------------------------------------------------------------
/app/fonts/ABCRepro-Black.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simliai/create-simli-app/HEAD/app/fonts/ABCRepro-Black.otf
--------------------------------------------------------------------------------
/app/fonts/ABCRepro-Black.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simliai/create-simli-app/HEAD/app/fonts/ABCRepro-Black.woff
--------------------------------------------------------------------------------
/app/fonts/ABCRepro-Bold.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simliai/create-simli-app/HEAD/app/fonts/ABCRepro-Bold.otf
--------------------------------------------------------------------------------
/app/fonts/ABCRepro-Bold.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simliai/create-simli-app/HEAD/app/fonts/ABCRepro-Bold.woff
--------------------------------------------------------------------------------
/app/fonts/ABCRepro-Bold.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simliai/create-simli-app/HEAD/app/fonts/ABCRepro-Bold.woff2
--------------------------------------------------------------------------------
/public/characters/Marie.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simliai/create-simli-app/HEAD/public/characters/Marie.jpg
--------------------------------------------------------------------------------
/app/fonts/ABCRepro-Black.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simliai/create-simli-app/HEAD/app/fonts/ABCRepro-Black.woff2
--------------------------------------------------------------------------------
/app/fonts/ABCRepro-Regular.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simliai/create-simli-app/HEAD/app/fonts/ABCRepro-Regular.otf
--------------------------------------------------------------------------------
/app/fonts/ABCRepro-Regular.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simliai/create-simli-app/HEAD/app/fonts/ABCRepro-Regular.woff
--------------------------------------------------------------------------------
/app/fonts/ABCReproMono-Bold.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simliai/create-simli-app/HEAD/app/fonts/ABCReproMono-Bold.otf
--------------------------------------------------------------------------------
/public/characters/einstein.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simliai/create-simli-app/HEAD/public/characters/einstein.jpg
--------------------------------------------------------------------------------
/app/fonts/ABCRepro-Regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simliai/create-simli-app/HEAD/app/fonts/ABCRepro-Regular.woff2
--------------------------------------------------------------------------------
/app/fonts/ABCReproMono-Bold.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simliai/create-simli-app/HEAD/app/fonts/ABCReproMono-Bold.woff
--------------------------------------------------------------------------------
/app/fonts/ABCReproMono-Bold.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simliai/create-simli-app/HEAD/app/fonts/ABCReproMono-Bold.woff2
--------------------------------------------------------------------------------
/app/fonts/ABCReproMono-Regular.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simliai/create-simli-app/HEAD/app/fonts/ABCReproMono-Regular.otf
--------------------------------------------------------------------------------
/app/fonts/ABCReproMono-Regular.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simliai/create-simli-app/HEAD/app/fonts/ABCReproMono-Regular.woff
--------------------------------------------------------------------------------
/app/fonts/ABCReproMono-Regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simliai/create-simli-app/HEAD/app/fonts/ABCReproMono-Regular.woff2
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 |
3 | const nextConfig = {
4 | reactStrictMode: true,
5 | }
6 | export default nextConfig;
7 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/basic-features/typescript for more information.
6 |
--------------------------------------------------------------------------------
/app/components/VideoBox.tsx:
--------------------------------------------------------------------------------
1 |
2 | export default function VideoBox(props: any) {
3 | return (
4 |
8 | );
9 | }
--------------------------------------------------------------------------------
/app/utils/TailwindMergeAndClsx.ts:
--------------------------------------------------------------------------------
1 | import { ClassValue, clsx } from 'clsx';
2 | import { twMerge } from 'tailwind-merge';
3 |
4 | /**
5 | * To merge Tailwind CSS classes
6 | *
7 | * Inspiration https://www.youtube.com/watch?v=re2JFITR7TI&t=349s
8 | */
9 | const cn = (...inputs: ClassValue[]) => {
10 | return twMerge(clsx(inputs));
11 | };
12 |
13 | export default cn;
14 |
--------------------------------------------------------------------------------
/app/components/DottedFace.tsx:
--------------------------------------------------------------------------------
1 |
2 | import Image from 'next/image';
3 | import dottedface from '@/media/dottedface.gif';
4 |
5 | export default function DottedFace(props: any) {
6 | return (
7 |
8 |
9 |
15 |
16 | );
17 | }
--------------------------------------------------------------------------------
/media/IconSparkleLoader.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import Image from "next/image";
3 | import cn from "@/app/utils/TailwindMergeAndClsx";
4 | import sparkle from "@/media/sparkle.svg";
5 |
6 | interface Props {
7 | className?: string;
8 | isBlack?: boolean;
9 | }
10 |
11 | const IconSparkleLoader = ({ className, isBlack = false }: Props) => {
12 | return (
13 |
21 | );
22 | };
23 |
24 | export default IconSparkleLoader;
25 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { Inter } from "next/font/google";
3 | import { abcRepro, abcReproMono } from './fonts/fonts';
4 | import "./globals.css";
5 |
6 |
7 | const inter = Inter({ subsets: ["latin"] });
8 |
9 | export const metadata: Metadata = {
10 | title: "Create Next App",
11 | description: "Generated by create next app",
12 | };
13 |
14 | export default function RootLayout({
15 | children,
16 | }: Readonly<{
17 | children: React.ReactNode;
18 | }>) {
19 | return (
20 |
21 | {children}
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | :root {
6 | --foreground-rgb: 0, 0, 0;
7 | --background-start-rgb: 214, 219, 220;
8 | --background-end-rgb: 255, 255, 255;
9 | }
10 |
11 | @media (prefers-color-scheme: dark) {
12 | :root {
13 | --foreground-rgb: 255, 255, 255;
14 | --background-start-rgb: 0, 0, 0;
15 | --background-end-rgb: 0, 0, 0;
16 | }
17 | }
18 |
19 | body {
20 | color: rgb(var(--foreground-rgb));
21 | background: linear-gradient(
22 | to bottom,
23 | transparent,
24 | rgb(var(--background-end-rgb))
25 | )
26 | rgb(var(--background-start-rgb));
27 | }
28 |
29 | @layer utilities {
30 | .text-balance {
31 | text-wrap: balance;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": [
4 | "dom",
5 | "dom.iterable",
6 | "esnext"
7 | ],
8 | "allowJs": true,
9 | "skipLibCheck": true,
10 | "strict": true,
11 | "noEmit": true,
12 | "esModuleInterop": true,
13 | "module": "esnext",
14 | "moduleResolution": "bundler",
15 | "resolveJsonModule": true,
16 | "isolatedModules": true,
17 | "jsx": "preserve",
18 | "incremental": true,
19 | "plugins": [
20 | {
21 | "name": "next"
22 | }
23 | ],
24 | "paths": {
25 | "@/*": [
26 | "./*"
27 | ]
28 | }
29 | },
30 | "include": [
31 | "next-env.d.ts",
32 | "**/*.ts",
33 | "**/*.tsx",
34 | ".next/types/**/*.ts"
35 | , "utils/validateApiKeys.ts" ],
36 | "exclude": [
37 | "node_modules"
38 | ]
39 | }
--------------------------------------------------------------------------------
/media/sparkle.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/media/github-mark-white.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/components/Logo.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import Image from 'next/image';
3 | import { usePathname, useRouter } from 'next/navigation';
4 | import React from 'react';
5 | import logo from '@/media/SimliLogoV2.svg';
6 | import cn from '@/app/utils/TailwindMergeAndClsx';
7 |
8 | interface Props {
9 | className?: string;
10 | children?: React.ReactNode;
11 | }
12 |
13 | const SimliHeaderLogo = ({ className, children }: Props) => {
14 | const router = useRouter();
15 | const pathname = usePathname();
16 |
17 | const handleClick = async () => {
18 | console.log('Clicked Simli logo', pathname);
19 | if (pathname === '/') {
20 | window.location.reload();
21 | return;
22 | }
23 | router.push('/');
24 | };
25 |
26 | return (
27 |
28 |
29 |
30 | );
31 | };
32 |
33 | export default SimliHeaderLogo;
34 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "create-simli-app",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start-server": "tsx server.ts",
9 | "start": "npm-run-all --parallel start-server dev",
10 | "lint": "next lint"
11 | },
12 | "dependencies": {
13 | "@deepgram/sdk": "^3.5.1",
14 | "axios": "^1.7.5",
15 | "clsx": "^2.1.1",
16 | "cors": "^2.8.5",
17 | "dotenv": "^16.4.5",
18 | "express": "^4.19.2",
19 | "groq-sdk": "^0.6.1",
20 | "next": "14.2.7",
21 | "openai": "^4.61.0",
22 | "react": "^18",
23 | "react-dom": "^18",
24 | "react-hot-toast": "^2.4.1",
25 | "simli-client": "^1.1.6",
26 | "tailwind-merge": "^2.5.2",
27 | "ws": "^8.18.0"
28 | },
29 | "devDependencies": {
30 | "@types/cors": "^2.8.17",
31 | "@types/express": "^5.0.0",
32 | "@types/node": "^20.17.10",
33 | "@types/react": "^18",
34 | "@types/react-dom": "^18",
35 | "@types/ws": "^8.5.13",
36 | "eslint": "^8",
37 | "eslint-config-next": "14.2.7",
38 | "npm-run-all": "^4.1.5",
39 | "postcss": "^8",
40 | "tailwindcss": "^3.4.1",
41 | "ts-node": "^10.9.2",
42 | "tsx": "^4.19.2",
43 | "typescript": "^5.7.2"
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/fonts/fonts.ts:
--------------------------------------------------------------------------------
1 | import localFont from 'next/font/local'
2 |
3 | // Regular font
4 | export const abcRepro = localFont({
5 | src: [
6 | {
7 | path: './ABCRepro-Regular.woff2',
8 | weight: '400',
9 | style: 'normal',
10 | },
11 | {
12 | path: './ABCRepro-Regular.woff',
13 | weight: '400',
14 | style: 'normal',
15 | },
16 | {
17 | path: './ABCRepro-Regular.otf',
18 | weight: '400',
19 | style: 'normal',
20 | },
21 | {
22 | path: './ABCRepro-Bold.woff2',
23 | weight: '700',
24 | style: 'normal',
25 | },
26 | {
27 | path: './ABCRepro-Bold.woff',
28 | weight: '700',
29 | style: 'normal',
30 | },
31 | {
32 | path: './ABCRepro-Bold.otf',
33 | weight: '700',
34 | style: 'normal',
35 | },
36 | {
37 | path: './ABCRepro-Black.woff2',
38 | weight: '800',
39 | style: 'oblique',
40 | },
41 | {
42 | path: './ABCRepro-Black.woff',
43 | weight: '800',
44 | style: 'oblique',
45 | },
46 | {
47 | path: './ABCRepro-Black.otf',
48 | weight: '800',
49 | style: 'oblique',
50 | },
51 | ],
52 | variable: '--font-abc-repro'
53 | })
54 |
55 | // Mono font
56 | export const abcReproMono = localFont({
57 | src: [
58 | {
59 | path: './ABCReproMono-Regular.woff',
60 | weight: '400',
61 | style: 'normal',
62 | },
63 | {
64 | path: './ABCReproMono-Bold.woff',
65 | weight: '800',
66 | style: 'normal',
67 | },
68 | ],
69 | variable: '--font-abc-repro-mono'
70 | })
71 |
--------------------------------------------------------------------------------
/app/components/Navbar.tsx:
--------------------------------------------------------------------------------
1 | export default function Navbar() {
2 | return (
3 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 | import colors from 'tailwindcss/colors';
3 |
4 | const config: Config = {
5 | content: [
6 | "./pages/**/*.{js,ts,jsx,tsx,mdx}",
7 | "./components/**/*.{js,ts,jsx,tsx,mdx}",
8 | "./app/**/*.{js,ts,jsx,tsx,mdx}",
9 | ],
10 | theme: {
11 | colors: {
12 | transparent: 'transparent',
13 | current: 'currentColor',
14 | black: colors.black,
15 | white: colors.white,
16 | gray: colors.gray,
17 | neutral: colors.neutral,
18 | zinc: colors.zinc,
19 | emerald: colors.emerald,
20 | indigo: colors.indigo,
21 | yellow: colors.yellow,
22 | blue: colors.blue,
23 | red: '#ff0000',
24 | simliblue: '#0000ff',
25 | simligray: '#111111'
26 | },
27 | extend: {
28 | fontFamily: {
29 | 'abc-repro-mono': ['var(--font-abc-repro-mono)'],
30 | 'abc-repro': ['var(--font-abc-repro)'],
31 | },
32 | backgroundImage: {
33 | "gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
34 | "gradient-conic":
35 | "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
36 | },
37 | keyframes: {
38 | fadeIn: {
39 | '0%': { opacity: '0' },
40 | '100%': { opacity: '1' },
41 | },
42 | spin: {
43 | '0%': { transform: 'rotate(0deg)' },
44 | '100%': { transform: 'rotate(359deg)' },
45 | },
46 | },
47 | animation: {
48 | fadeIn: 'fadeIn 1s ease-in-out',
49 | loader: 'spin 5s linear infinite',
50 | },
51 | },
52 | },
53 | plugins: [],
54 | };
55 |
56 | export default config;
--------------------------------------------------------------------------------
/utils/validateApiKeys.ts:
--------------------------------------------------------------------------------
1 | interface ApiKeyConfig {
2 | key: string;
3 | name: string;
4 | pattern?: RegExp;
5 | required: boolean;
6 | }
7 |
8 | const API_KEY_CONFIGS: ApiKeyConfig[] = [
9 | {
10 | key: 'NEXT_PUBLIC_SIMLI_API_KEY',
11 | name: 'Public Simli Key',
12 | pattern: /^[a-z0-9]{18,30}$/,
13 | required: true
14 | },
15 | {
16 | key: 'ELEVENLABS_API_KEY',
17 | name: 'ElevenLabs',
18 | pattern: /^(?:[a-f0-9]{32}|sk-[A-Za-z0-9-]{45,60})$/, // 32 char hex or sk-none- format
19 | required: true
20 | },
21 | {
22 | key: 'OPENAI_API_KEY',
23 | name: 'OpenAI',
24 | pattern: /^sk-[A-Za-z0-9-]{45,60}$/, // starts with sk- followed by 45-57 chars (total 48-60)
25 | required: true
26 | },
27 | {
28 | key: 'DEEPGRAM_API_KEY',
29 | name: 'Deepgram',
30 | pattern: /^[a-f0-9]{40}$/, // 40 char hex
31 | required: true
32 | }
33 | ];
34 |
35 | export function validateApiKeys(): { valid: boolean; errors: string[] } {
36 | const errors: string[] = [];
37 |
38 | for (const config of API_KEY_CONFIGS) {
39 | const value = process.env[config.key];
40 |
41 | // Check if key exists
42 | if (!value) {
43 | if (config.required) {
44 | errors.push(`${config.name} API key (${config.key}) is missing`);
45 | }
46 | continue;
47 | }
48 |
49 | // Check if key matches expected pattern
50 | if (config.pattern && !config.pattern.test(value)) {
51 | errors.push(`${config.name} API key (${config.key}) is invalid format`);
52 | }
53 | }
54 |
55 | return {
56 | valid: errors.length === 0,
57 | errors
58 | };
59 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Create Simli App
2 | This starter is an example of how to create a composable Simli interaction that runs in a Next.js app.
3 | The project consists of a Next.js app that uses the Simli SDK (`simli-client`) and a server `server.ts` that handles the interaction with other services such as speech-to-text (STT), large language models (LLMs) and text-to-speech (TTS).
4 |
5 | ### Environment variables
6 | Start by signing up and getting your API key from [Simli.com](https://www.simli.com/). Then, fill in the `.env` file in the root of the project and put in the following environment variables:
7 |
8 | ```bash
9 | NEXT_PUBLIC_SIMLI_API_KEY="API key from simli.com"
10 | ELEVENLABS_API_KEY="Paid API key from elevenlabs.io (Free API key doesn't allow streaming audio)"
11 | DEEPGRAM_API_KEY="API key from deepgram.com"
12 | OPENAI_API_KEY="API key from OPENAI"
13 | ```
14 |
15 | If you want to try Simli but don't have API access to these third parties, ask in Discord and we can help you out with that ([Discord Link](https://discord.gg/yQx49zNF4d)).
16 |
17 | To run the back-end and front-end together, run the following command:
18 |
19 |
20 | ```bash
21 | npm run start
22 | ```
23 |
24 | ### Characters
25 | You can swap out the character by finding one that you like in the [docs](https://docs.simli.com/introduction), or [create your own](https://app.simli.com/)
26 |
27 |      
28 |
29 | ### Alternative STT, TTS and LLM providers
30 | You can of course replace Deepgram and Elevenlabs with AI services with your own preference, or even build your own.
31 | The only requirement for Simli to work is that audio is sent using PCM16 format and 16KHz sample rate or sending it through MediaStream. If you're having trouble getting nice audio, feel free to ask for help in Discord.
32 |
33 | ## Links
34 | [\[Simli\]](https://simli.com) [\[Elevenlabs\]](https://elevenlabs.io) [\[Deepgram\]](https://deepgram.com)
35 | [\[Groq\]](https://groq.com)
36 |
37 |
38 | ## Deploy on Vercel
39 |
40 | An easy way to deploy your avatar interaction to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme).
41 |
--------------------------------------------------------------------------------
/media/SimliLogoV2.svg:
--------------------------------------------------------------------------------
1 |
13 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 | .pnpm-debug.log*
9 |
10 | # Diagnostic reports (https://nodejs.org/api/report.html)
11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
12 |
13 | # Runtime data
14 | pids
15 | *.pid
16 | *.seed
17 | *.pid.lock
18 |
19 | # Directory for instrumented libs generated by jscoverage/JSCover
20 | lib-cov
21 |
22 | # Coverage directory used by tools like istanbul
23 | coverage
24 | *.lcov
25 |
26 | # nyc test coverage
27 | .nyc_output
28 |
29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
30 | .grunt
31 |
32 | # Bower dependency directory (https://bower.io/)
33 | bower_components
34 |
35 | # node-waf configuration
36 | .lock-wscript
37 |
38 | # Compiled binary addons (https://nodejs.org/api/addons.html)
39 | build/Release
40 |
41 | # Dependency directories
42 | node_modules/
43 | jspm_packages/
44 |
45 | # Snowpack dependency directory (https://snowpack.dev/)
46 | web_modules/
47 |
48 | # TypeScript cache
49 | *.tsbuildinfo
50 |
51 | # Optional npm cache directory
52 | .npm
53 |
54 | # Optional eslint cache
55 | .eslintcache
56 |
57 | # Optional stylelint cache
58 | .stylelintcache
59 |
60 | # Microbundle cache
61 | .rpt2_cache/
62 | .rts2_cache_cjs/
63 | .rts2_cache_es/
64 | .rts2_cache_umd/
65 |
66 | # Optional REPL history
67 | .node_repl_history
68 |
69 | # Output of 'npm pack'
70 | *.tgz
71 |
72 | # Yarn Integrity file
73 | .yarn-integrity
74 |
75 | # dotenv environment variable files
76 | .env.development.local
77 | .env.test.local
78 | .env.production.local
79 | .env.local
80 |
81 | # parcel-bundler cache (https://parceljs.org/)
82 | .cache
83 | .parcel-cache
84 |
85 | # Next.js build output
86 | .next
87 | out
88 |
89 | # Nuxt.js build / generate output
90 | .nuxt
91 | dist
92 |
93 | # Gatsby files
94 | .cache/
95 | # Comment in the public line in if your project uses Gatsby and not Next.js
96 | # https://nextjs.org/blog/next-9-1#public-directory-support
97 | # public
98 |
99 | # vuepress build output
100 | .vuepress/dist
101 |
102 | # vuepress v2.x temp and cache directory
103 | .temp
104 | .cache
105 |
106 | # Docusaurus cache and generated files
107 | .docusaurus
108 |
109 | # Serverless directories
110 | .serverless/
111 |
112 | # FuseBox cache
113 | .fusebox/
114 |
115 | # DynamoDB Local files
116 | .dynamodb/
117 |
118 | # TernJS port file
119 | .tern-port
120 |
121 | # Stores VSCode versions used for testing VSCode extensions
122 | .vscode-test
123 |
124 | # yarn v2
125 | .yarn/cache
126 | .yarn/unplugged
127 | .yarn/build-state.yml
128 | .yarn/install-state.gz
129 | .pnp.*
130 | .env
131 |
--------------------------------------------------------------------------------
/app/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import AvatarInteraction from "@/app/AvatarInteraction";
3 | import DottedFace from "@/app/components/DottedFace";
4 | import SimliHeaderLogo from "@/app/components/Logo";
5 | import Navbar from "@/app/components/Navbar";
6 | import GitHubLogo from "@/media/github-mark-white.svg";
7 | import Image from "next/image";
8 | import React, { useState } from "react";
9 | import { Toaster } from "react-hot-toast";
10 |
11 | // Update the Avatar interface to include an image URL
12 | interface Avatar {
13 | name: string;
14 | simli_faceid: string;
15 | elevenlabs_voiceid: string;
16 | initialPrompt: string;
17 | }
18 |
19 | // Updated JSON structure for avatar data with image URLs
20 | const avatar: Avatar = {
21 | name: "Chrystal",
22 | simli_faceid: "b7da5ed1-2abc-47c8-b7a6-0b018e031a26",
23 | elevenlabs_voiceid: "cgSgspJ2msm6clMCkdW9",
24 | initialPrompt:
25 | "You are a support agent for Simli and you're living in local Create-Simli-App, the interactive demo for Simli that you can start building from. You can swap me out with other characters.",
26 | };
27 |
28 | const Demo: React.FC = () => {
29 | const [error, setError] = useState("");
30 | const [showDottedFace, setShowDottedFace] = useState(true);
31 |
32 | const onStart = () => {
33 | console.log("Setting setshowDottedface to false...");
34 | setShowDottedFace(false);
35 | };
36 |
37 | return (
38 | <>
39 |
40 |
41 |
42 |
43 |
44 | {
46 | window.open("https://github.com/simliai/create-simli-app");
47 | }}
48 | className="font-bold cursor-pointer mb-8 text-xl leading-8"
49 | >
50 |
51 | create-simli-app
52 |
53 |
54 |
55 |
56 | {showDottedFace &&
}
57 |
64 |
65 |
66 |
67 |
68 |
69 | {" "}
70 | Create Simli App is a starter repo for creating an interactive app
71 | with Simli.{" "}
72 |
73 |
74 | - Fill in your API keys in the .env file.
75 | -
76 | Test out the interaction and have a conversation with our default
77 | avatar.
78 |
79 | -
80 | You can replace the avatar's face and voice and initial prompt with
81 | your own. Do this by editing
app/page.tsx.
82 |
83 |
84 |
85 | You can now deploy this app to Vercel, or incorporate it as part of
86 | your existing project.
87 |
88 |
89 | {error && (
90 |
91 | {error}
92 |
93 | )}
94 |
95 | >);
96 | };
97 |
98 | export default Demo;
99 |
--------------------------------------------------------------------------------
/app/AvatarInteraction.tsx:
--------------------------------------------------------------------------------
1 | import VideoBox from '@/app/components/VideoBox';
2 | import cn from '@/app/utils/TailwindMergeAndClsx';
3 | import IconSparkleLoader from "@/media/IconSparkleLoader";
4 | import React, { useCallback, useEffect, useRef, useState } from 'react';
5 | import { SimliClient } from 'simli-client';
6 |
7 | interface AvatarInteractionProps {
8 | simli_faceid: string;
9 | elevenlabs_voiceid: string;
10 | initialPrompt: string;
11 | onStart: () => void;
12 | showDottedFace: boolean;
13 | }
14 |
15 | const simliClient = new SimliClient();
16 |
17 | const AvatarInteraction: React.FC = ({
18 | simli_faceid,
19 | elevenlabs_voiceid,
20 | initialPrompt,
21 | onStart,
22 | showDottedFace
23 | }) => {
24 | const [isLoading, setIsLoading] = useState(false);
25 | const [isAvatarVisible, setIsAvatarVisible] = useState(false);
26 | const [error, setError] = useState('');
27 | const [audioStream, setAudioStream] = useState(null);
28 | const videoRef = useRef(null);
29 | const audioRef = useRef(null);
30 | const socketRef = useRef(null);
31 |
32 | const initializeSimliClient = useCallback(() => {
33 | if (videoRef.current && audioRef.current) {
34 | simliClient.Initialize({
35 | apiKey: process.env.NEXT_PUBLIC_SIMLI_API_KEY || '',
36 | faceID: simli_faceid,
37 | handleSilence: true,
38 | maxSessionLength: 200,
39 | maxIdleTime: 100,
40 | videoRef: videoRef,
41 | audioRef: audioRef,
42 | });
43 | console.log('Simli Client initialized');
44 | }
45 | }, [simli_faceid]);
46 |
47 | const startRecording = async () => {
48 | try {
49 | const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
50 | setAudioStream(stream);
51 | } catch (err) {
52 | console.error('Error accessing microphone:', err);
53 | setError('Error accessing microphone. Please check your permissions.');
54 | }
55 | };
56 |
57 | const initializeWebSocket = useCallback((connectionId: string) => {
58 | socketRef.current = new WebSocket(`ws://localhost:8080/ws?connectionId=${connectionId}`);
59 |
60 | socketRef.current.onopen = () => {
61 | console.log('Connected to server');
62 | };
63 |
64 | socketRef.current.onmessage = (event) => {
65 | if (event.data instanceof Blob) {
66 | event.data.arrayBuffer().then((arrayBuffer) => {
67 | const uint8Array = new Uint8Array(arrayBuffer);
68 | simliClient.sendAudioData(uint8Array);
69 | });
70 | } else {
71 | try {
72 | const message = JSON.parse(event.data);
73 | if (message.type === 'interrupt') {
74 | console.log('Interrupting current response');
75 | simliClient.ClearBuffer();
76 | } else if (message.type === 'text') {
77 | // const uint8Array = new Uint8Array(6000).fill(0);
78 | // simliClient.sendAudioData(uint8Array);
79 | }
80 | } catch (error) {
81 | console.error('Error parsing WebSocket message:', error);
82 | }
83 | }
84 | };
85 |
86 | socketRef.current.onerror = (error) => {
87 | console.error('WebSocket error:', error);
88 | setError('WebSocket connection error. Please check if the server is running.');
89 | };
90 | }, []);
91 |
92 | const startConversation = useCallback(async () => {
93 | try {
94 | const response = await fetch('http://localhost:8080/start-conversation', {
95 | method: 'POST',
96 | headers: {
97 | 'Content-Type': 'application/json',
98 | },
99 | body: JSON.stringify({
100 | prompt: initialPrompt,
101 | voiceId: elevenlabs_voiceid
102 | }),
103 | });
104 |
105 | if (!response.ok) {
106 | const data = await response.json();
107 | throw new Error(data.error);
108 | }
109 |
110 | const data = await response.json();
111 | initializeWebSocket(data.connectionId);
112 | } catch (error) {
113 | console.error('Error starting conversation:', error);
114 | window.alert(`Whoopsie! Encountered the following error(s):\n\n[${error}].\n\nTry fixing those and restarting the application (npm run start).`)
115 | handleStop();
116 | setError('Failed to start conversation. Please try again.');
117 | }
118 | }, [elevenlabs_voiceid, initialPrompt, initializeWebSocket]);
119 |
120 | const handleStart = useCallback(async () => {
121 | setIsLoading(true);
122 | setError('');
123 | onStart();
124 |
125 | await startConversation();
126 | simliClient.start();
127 | startRecording();
128 | }, [onStart, startConversation]);
129 |
130 | const handleStop = useCallback(() => {
131 | setIsLoading(false);
132 | setError('');
133 | setIsAvatarVisible(false);
134 | setAudioStream(null);
135 |
136 | if (audioStream) {
137 | audioStream.getTracks().forEach(track => track.stop());
138 | }
139 |
140 | simliClient.close();
141 | socketRef.current?.close();
142 | window.location.href = '/';
143 | }, [audioStream]);
144 |
145 | useEffect(() => {
146 | if (simliClient) {
147 | simliClient.on('connected', () => {
148 | console.log('SimliClient connected');
149 | setIsAvatarVisible(true);
150 | const audioData = new Uint8Array(6000).fill(0);
151 | simliClient.sendAudioData(audioData);
152 | });
153 | }
154 | }, []);
155 |
156 | useEffect(() => {
157 | initializeSimliClient();
158 |
159 | return () => {
160 | if (socketRef.current) {
161 | socketRef.current.close();
162 | }
163 | simliClient.close();
164 | };
165 | }, [initializeSimliClient]);
166 |
167 | useEffect(() => {
168 | if (audioStream && socketRef.current && socketRef.current.readyState === WebSocket.OPEN) {
169 | const mediaRecorder = new MediaRecorder(audioStream);
170 |
171 | mediaRecorder.ondataavailable = (event) => {
172 | if (event.data.size > 0) {
173 | socketRef.current?.send(event.data);
174 | }
175 | };
176 |
177 | mediaRecorder.start(100);
178 |
179 | return () => {
180 | mediaRecorder.stop();
181 | };
182 | }
183 | }, [audioStream]);
184 |
185 | return (
186 | <>
187 |
191 |
192 |
193 |
194 | {!isAvatarVisible ? (
195 |
211 | ) : (
212 |
213 |
223 |
224 | )}
225 |
226 | {error && (
227 | {error}
228 | )}
229 | >
230 | );
231 | };
232 |
233 | export default AvatarInteraction;
--------------------------------------------------------------------------------
/server.ts:
--------------------------------------------------------------------------------
1 | import { createClient, LiveTranscriptionEvents } from '@deepgram/sdk';
2 | import cors from 'cors';
3 | import dotenv from 'dotenv';
4 | import express from 'express';
5 | import http from 'http';
6 | import OpenAI from 'openai';
7 | import url from 'url';
8 | import { WebSocket } from 'ws';
9 | import { validateApiKeys } from './utils/validateApiKeys';
10 |
11 | dotenv.config();
12 |
13 | const app = express();
14 | app.use(cors());
15 | app.use(express.json());
16 | const server = http.createServer(app);
17 |
18 | const deepgramClient = createClient(process.env.DEEPGRAM_API_KEY);
19 |
20 | const openai = new OpenAI({
21 | apiKey: process.env.OPENAI_API_KEY || '' // Provide empty string as fallback
22 | });
23 |
24 | let currentOpenAIStream: { stream: AsyncIterable; controller: AbortController } | null = null; // Track current OpenAI stream
25 |
26 | // Connection manager to keep track of active connections
27 | const connections = new Map();
28 | app.post('/start-conversation', (req: any, res: any) => {
29 | const { prompt, voiceId } = req.body as { prompt: string; voiceId: string };
30 | if (!prompt || !voiceId) {
31 | return res.status(400).json({ error: 'Prompt and voiceId are required' });
32 | }
33 |
34 | const validate = validateApiKeys();
35 | if (!validate.valid) {
36 | console.error('API key validation failed. Fix the following errors and run `npm run start` again:', validate.errors)
37 | return res.status(400).json({ error: 'API key invalid: ' + validate.errors });
38 | }
39 |
40 | const connectionId = Date.now().toString();
41 | connections.set(connectionId, { prompt, voiceId });
42 | res.json({ connectionId, message: 'Conversation started. Connect to WebSocket to continue.' });
43 | });
44 |
45 | const wss = new WebSocket.Server({ noServer: true });
46 |
47 | server.on('upgrade', (request, socket, head) => {
48 | // Make sure url is defined
49 | if (!request.url) {
50 | socket.destroy();
51 | return;
52 | }
53 |
54 | const { pathname, query } = url.parse(request.url, true);
55 |
56 | if (pathname === '/ws') {
57 | const connectionId = query.connectionId;
58 | if (!connectionId || !connections.has(connectionId)) {
59 | socket.destroy();
60 | return;
61 | }
62 |
63 | wss.handleUpgrade(request, socket, head, (ws) => {
64 | const connection = connections.get(connectionId);
65 | console.log(`WebSocket: Client connected (ID: ${connectionId})`);
66 | setupWebSocket(ws, connection.prompt, connection.voiceId, connectionId);
67 | });
68 | } else {
69 | socket.destroy();
70 | }
71 | });
72 |
73 | const setupWebSocket = (ws: WebSocket, initialPrompt: string, voiceId: string, connectionId: string | string[]) => {
74 | let is_finals: string[] = [];
75 | let audioQueue: any[] = [];
76 | let keepAlive: NodeJS.Timeout;
77 | currentOpenAIStream = null; // Track current OpenAI stream
78 |
79 | const deepgram = deepgramClient.listen.live({
80 | model: "nova-2",
81 | language: "en",
82 | smart_format: true,
83 | no_delay: true,
84 | interim_results: true,
85 | endpointing: 300,
86 | utterance_end_ms: 1000
87 | });
88 |
89 | deepgram.addListener(LiveTranscriptionEvents.Open, () => {
90 | console.log(`Deepgram STT: Connected (ID: ${connectionId})`);
91 | while (audioQueue.length > 0) {
92 | const audioData = audioQueue.shift();
93 | deepgram.send(audioData);
94 | }
95 | });
96 |
97 | deepgram.addListener(LiveTranscriptionEvents.Transcript, (data) => {
98 | const transcript = data.channel.alternatives[0].transcript;
99 | if (transcript !== "") {
100 | if (data.is_final) {
101 | is_finals.push(transcript);
102 | if (data.speech_final) {
103 | const utterance = is_finals.join(" ");
104 | is_finals = [];
105 | console.log(`Deepgram STT: [Speech Final] ${utterance} (ID: ${connectionId})`);
106 |
107 | // Only attempt to interrupt if there's an active openAI stream
108 | if (currentOpenAIStream) {
109 | console.log('Interrupting current stream');
110 | currentOpenAIStream.controller.abort();
111 | currentOpenAIStream = null;
112 | ws.send(JSON.stringify({ type: 'interrupt' }));
113 | }
114 |
115 | promptLLM(ws, initialPrompt, utterance, voiceId, connectionId);
116 |
117 | } else {
118 | console.log(`Deepgram STT: [Is Final] ${transcript} (ID: ${connectionId})`);
119 | }
120 | } else {
121 | console.log(`Deepgram STT: [Interim Result] ${transcript} (ID: ${connectionId})`);
122 | }
123 | }
124 | });
125 |
126 | deepgram.addListener(LiveTranscriptionEvents.UtteranceEnd, () => {
127 | if (is_finals.length > 0) {
128 | const utterance = is_finals.join(" ");
129 | is_finals = [];
130 | initialPrompt = "" // empty prompt
131 | console.log(`Deepgram STT: [Speech Final] ${utterance} (ID: ${connectionId})`);
132 | promptLLM(ws, initialPrompt, utterance, voiceId, connectionId);
133 | }
134 | });
135 |
136 | deepgram.addListener(LiveTranscriptionEvents.Close, () => {
137 | console.log(`Deepgram STT: Disconnected (ID: ${connectionId})`);
138 | clearInterval(keepAlive);
139 | deepgram.removeAllListeners();
140 | });
141 |
142 | deepgram.addListener(LiveTranscriptionEvents.Error, (error) => {
143 | console.error(`Deepgram STT error (ID: ${connectionId}):`, error);
144 | });
145 |
146 | ws.on("message", (message: any) => {
147 | //console.log(`WebSocket: Client data received (ID: ${connectionId})`, typeof message, message.length, "bytes");
148 |
149 | if (deepgram.getReadyState() === 1) {
150 | deepgram.send(message);
151 | } else {
152 | console.log(`WebSocket: Data queued for Deepgram. Current state: ${deepgram.getReadyState()} (ID: ${connectionId})`);
153 | audioQueue.push(message);
154 | }
155 | });
156 |
157 | ws.on("close", () => {
158 | console.log(`WebSocket: Client disconnected (ID: ${connectionId})`);
159 | clearInterval(keepAlive);
160 | deepgram.removeAllListeners();
161 | connections.delete(connectionId);
162 | });
163 |
164 | keepAlive = setInterval(() => {
165 | deepgram.keepAlive();
166 | }, 10 * 1000);
167 |
168 | connections.set(connectionId, { ...connections.get(connectionId), ws, deepgram });
169 | }
170 |
171 | async function promptLLM(ws: WebSocket, initialPrompt: string, prompt: string, voiceId: string, connectionId: string | string[]) {
172 | try {
173 | const controller = new AbortController(); // Create abort controller
174 | const stream = await openai.chat.completions.create({
175 | model: "gpt-4o-mini",
176 | messages: [
177 | {
178 | role: 'assistant',
179 | content: initialPrompt
180 | },
181 | {
182 | role: 'user',
183 | content: prompt
184 | }
185 | ],
186 | temperature: 1,
187 | max_tokens: 50,
188 | top_p: 1,
189 | stream: true,
190 | }, { signal: controller.signal }); // Add abort signal
191 |
192 | currentOpenAIStream = { stream, controller }; // Store stream and controller
193 |
194 | let fullResponse: string = '';
195 | let elevenLabsWs: any | undefined = undefined;
196 |
197 | try {
198 | for await (const chunk of stream) {
199 | if (!connections.has(connectionId)) {
200 | console.log(`LLM process stopped: Connection ${connectionId} no longer exists`);
201 | break;
202 | }
203 |
204 | const chunkMessage = chunk.choices[0]?.delta?.content || '';
205 | fullResponse += chunkMessage;
206 |
207 | ws.send(JSON.stringify({ type: 'text', content: chunkMessage }));
208 |
209 | if (!elevenLabsWs && fullResponse.length > 0) {
210 | elevenLabsWs = await startElevenLabsStreaming(ws, voiceId, connectionId);
211 | }
212 |
213 | if (elevenLabsWs && chunkMessage) {
214 | const contentMessage = {
215 | text: chunkMessage,
216 | try_trigger_generation: true,
217 | };
218 | elevenLabsWs.send(JSON.stringify(contentMessage));
219 | }
220 | }
221 | } catch (error: any) {
222 | if (error.name === 'AbortError') {
223 | console.log('OpenAI stream aborted due to new speech');
224 | if (elevenLabsWs) {
225 | elevenLabsWs.close();
226 | }
227 | } else {
228 | throw error;
229 | }
230 | }
231 |
232 | currentOpenAIStream = null; // Clear current stream reference
233 |
234 | if (elevenLabsWs) {
235 | elevenLabsWs.send(JSON.stringify({ text: "", try_trigger_generation: true }));
236 | }
237 |
238 | } catch (error) {
239 | console.error(`Error in promptLLM (ID: ${connectionId}):`, error);
240 | }
241 | }
242 |
243 | async function startElevenLabsStreaming(ws: WebSocket, voiceId: string, connectionId: string | string[]) {
244 | return new Promise((resolve, reject) => {
245 | const elevenLabsWs = new WebSocket(`wss://api.elevenlabs.io/v1/text-to-speech/${voiceId}/stream-input?model_id=eleven_turbo_v2_5&output_format=pcm_16000`);
246 |
247 | elevenLabsWs.on('open', () => {
248 | console.log(`Connected to ElevenLabs WebSocket (ID: ${connectionId})`);
249 | const initialMessage = {
250 | text: " ",
251 | voice_settings: {
252 | stability: 0.5,
253 | similarity_boost: 0.5
254 | },
255 | xi_api_key: process.env.ELEVENLABS_API_KEY,
256 | };
257 | elevenLabsWs.send(JSON.stringify(initialMessage));
258 | resolve(elevenLabsWs);
259 | });
260 |
261 | elevenLabsWs.on('message', (data: any) => {
262 | if (!connections.has(connectionId)) {
263 | console.log(`ElevenLabs process stopped: Connection ${connectionId} no longer exists`);
264 | elevenLabsWs.close();
265 | return;
266 | }
267 |
268 | const message = JSON.parse(data);
269 | if (message.audio) {
270 | const audioData = Buffer.from(message.audio, 'base64');
271 | const chunkSize = 5 * 1024; // 5KB
272 | let i = 0;
273 | while (i < audioData.length) {
274 | const end = Math.min(i + chunkSize, audioData.length);
275 | const chunk = audioData.slice(i, end);
276 | ws.send(chunk);
277 | i += chunkSize;
278 | }
279 | } else if (message.isFinal) {
280 | console.log(`ElevenLabs streaming completed (ID: ${connectionId})`);
281 | elevenLabsWs.close();
282 | }
283 | });
284 |
285 | elevenLabsWs.on('error', (error) => {
286 | console.error(`ElevenLabs WebSocket error (ID: ${connectionId}):`, error);
287 | reject(error);
288 | });
289 |
290 | elevenLabsWs.on('close', () => {
291 | console.log(`ElevenLabs WebSocket closed (ID: ${connectionId})`);
292 | });
293 | });
294 | }
295 |
296 | const port = 8080;
297 | server.listen(port, () => {
298 | console.log(`Server running on http://localhost:${port}`);
299 | });
--------------------------------------------------------------------------------