├── 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 |
5 | 6 | 7 |
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 | loading... 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 | loader 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 | 2 | 3 | 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 | Simli logo 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 |
4 | 38 |
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 | ![alt text](media/image.png) ![alt text](media/image-4.png) ![alt text](media/image-2.png) ![alt text](media/image-3.png) ![alt text](media/image-5.png) ![alt text](media/image-6.png) 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 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 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 | }); --------------------------------------------------------------------------------