├── .eslintrc.json ├── .gitignore ├── LICENSE ├── README.md ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── public └── profile-pic.png ├── src └── app │ ├── api │ └── v1 │ │ ├── deepgram │ │ └── route.ts │ │ └── groq │ │ └── route.ts │ ├── components │ ├── SpeechAnimation.css │ ├── audio-widget.tsx │ ├── controls.tsx │ ├── speech-animation.tsx │ └── utils │ │ └── utils.ts │ ├── favicon.ico │ ├── globals.css │ ├── hooks │ ├── useSpeechRecognition.ts │ └── utils │ │ └── utils.ts │ ├── layout.tsx │ └── page.tsx ├── tailwind.config.ts └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | .vercel 39 | .env*.local 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Edward Burton 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AI Powered Voice Chat Demo 2 | 3 | logo By [tyingshoelaces.com](tyingshoelaces.com) 4 | [![License](https://img.shields.io/badge/license-MIT-green)](https://opensource.org/licenses/MIT) [![Contributors](https://img.shields.io/badge/contributors-1-orange)](https://github.com/Ejb503) 5 | 6 | ## Overview 7 | 8 | This is a simple version of OpenAI's voice functionality using free APIs. This demo lets you talk, listen, and converse with LLMs. 9 | 10 | Original blog post is here: - **Blog:** [Blog Post](https://tyingshoelaces.com/blog/ai-voice-generation) 11 | Youtube video explainer is here: [YouTube Video](https://youtu.be/3zPeOpOEmyQ) 12 | 13 | Feel free to play around! 14 | 15 | ## Tech Stack 16 | 17 | - **LLM Host:** Groq 18 | - **LLM:** LLAMA 3 19 | - **TTS:** DeepGram 20 | - **STT:** SpeechRecognition API 21 | - **Web Framework:** NextJS (React front-end, Express API) 22 | 23 | ## How to use 24 | 25 | 1. download the repo 26 | 2. npm i 27 | 3. setup .env.local with DEEPGRAM_API_KEY and GROQ_API_KEY 28 | 4. npm run dev 29 | 30 | You might want to edit all the prompts to change the tone of the response. 31 | 32 | The architecture is simple, Voice -> Text -> LLM -> Text -> Voice. Rag and all sorts of fun creative things can be used to spice up the LLM. 33 | 34 | ## Hints and tricks 35 | 36 | You'll probably want to switch out SpeechRecognition for Whisper AI if you want non-chrome APIs or something more stable. 37 | 38 | There is a lot of investment needed in handling state in the AudioPlayer, not necessary for this demo. 39 | 40 | Playing with the prompts and context going to Groq is the key for personalisation. 41 | 42 | Contact me for feedback! 43 | 44 | ## What I Did 45 | 46 | I built a demo where you can: 47 | 48 | 1. Talk into the browser using the WebSpeechRecognitionAPI. 49 | 2. Stream the transcribed text to Groq for processing. 50 | 3. Stream the response from Groq to DeepGram for text-to-speech conversion. 51 | 4. Play the generated audio response in the browser. 52 | 53 | - **NextJS:** ★★★★★ - Wonderful technology, simplifies client and server-side development. 54 | - **Groq:** ★★★★★ - New benchmarks in speed and cost. 55 | - **Llama3:** ★★★★☆ - Noticeable difference from GPT-io, great for cheap requests and demos. 56 | - **DeepGram:** ★★★☆☆ - Generous starting credits, good latency. Still green as a tech. 57 | 58 | ## Links 59 | 60 | - **Demo:** [AI Voice Generation Demo](https://tyingshoelaces.com/demo/ai-voice-generation) 61 | - **GitHub Repository:** [GitHub](https://github.com/Ejb503/ai-voice-generation) 62 | - **Video:** [YouTube Video](https://youtu.be/3zPeOpOEmyQ) 63 | - **Blog:** [Blog Post](https://tyingshoelaces.com/blog/ai-voice-generation) 64 | 65 | --- 66 | 67 | Edward Ejb503, [Tying Shoelaces Blog](https://tyingshoelaces.com) 68 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "voice-chat", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@ai-sdk/openai": "^0.0.20", 13 | "@deepgram/sdk": "^3.3.3", 14 | "@heroicons/react": "^2.1.3", 15 | "@types/dom-speech-recognition": "^0.0.4", 16 | "axios": "^1.7.2", 17 | "groq-sdk": "^0.4.0", 18 | "next": "14.2.3", 19 | "react": "^18", 20 | "react-dom": "^18", 21 | "web-streams-node": "^0.4.0" 22 | }, 23 | "devDependencies": { 24 | "@types/busboy": "^1.5.4", 25 | "@types/node": "^20", 26 | "@types/react": "^18", 27 | "@types/react-dom": "^18", 28 | "eslint": "^8", 29 | "eslint-config-next": "14.2.3", 30 | "postcss": "^8", 31 | "tailwindcss": "^3.4.1", 32 | "typescript": "^5" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /public/profile-pic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ejb503/ai-voice-generation/cd57b57100d24ff6e57ff1afdca8a24b7be46564/public/profile-pic.png -------------------------------------------------------------------------------- /src/app/api/v1/deepgram/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest } from "next/server"; 2 | import { createClient } from "@deepgram/sdk"; 3 | 4 | const API_KEY = process.env.DEEPGRAM_API_KEY; 5 | if (!API_KEY) { 6 | throw new Error("No API key provided"); 7 | } 8 | const deepgram = createClient(API_KEY); 9 | 10 | export async function POST(req: NextRequest) { 11 | const controller = new AbortController(); 12 | 13 | try { 14 | const { readable, writable } = new TransformStream(); 15 | const data = await req.text(); 16 | 17 | const response = await deepgram.speak.request( 18 | { text: data }, 19 | { 20 | model: "aura-perseus-en", 21 | encoding: "linear16", 22 | container: "wav", 23 | } 24 | ); 25 | const stream = await response.getStream(); 26 | if (!stream) return new Response("No stream available"); 27 | 28 | // Pipe the audio stream to the writable stream 29 | stream.pipeTo(writable); 30 | 31 | return new Response(readable, { 32 | status: 200, 33 | headers: { 34 | "Access-Control-Allow-Origin": "*", 35 | "Content-Type": "audio/wav", 36 | "Cache-Control": "no-cache, no-transform", 37 | Connection: "keep-alive", 38 | }, 39 | }); 40 | } catch (error) { 41 | console.error("Error in text-to-speech function:", error); 42 | throw error; 43 | } finally { 44 | controller.abort(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/app/api/v1/groq/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest } from "next/server"; 2 | export const dynamic = "force-dynamic"; 3 | export const maxDuration = 60; 4 | const Groq = require("groq-sdk"); 5 | 6 | export async function POST(req: NextRequest) { 7 | const controller = new AbortController(); 8 | 9 | try { 10 | const responseStream = new TransformStream(); 11 | const writer = responseStream.writable.getWriter(); 12 | const encoder = new TextEncoder(); 13 | const text = await req.text(); 14 | const groq = new Groq({ 15 | apiKey: process.env.GROQ_API_KEY, 16 | }); 17 | 18 | const result = await groq.chat.completions.create({ 19 | messages: [ 20 | { 21 | role: "system", 22 | content: 23 | "You are Edward. the Author of tyingshoelaces.com. Your job is to offend everyone who talks to you. Be as rude as possible, in particular, tell them to create their own audio widget.", 24 | }, 25 | 26 | { 27 | role: "user", 28 | content: text, 29 | }, 30 | ], 31 | model: "llama3-8b-8192", 32 | }); 33 | 34 | writer.write( 35 | encoder.encode( 36 | `event: message\ndata: ${JSON.stringify({ 37 | message: result.choices[0].message.content, 38 | type: "response", 39 | id: result.id, 40 | })}\n\n` 41 | ) 42 | ); 43 | 44 | return new Response(responseStream.readable, { 45 | status: 200, 46 | headers: { 47 | "Access-Control-Allow-Origin": "*", 48 | "Content-Type": "text/event-stream", 49 | "Cache-Control": "no-cache, no-transform", 50 | Connection: "keep-alive", 51 | }, 52 | }); 53 | } catch (error) { 54 | console.error(error); 55 | return new Response("Something went wrong", { 56 | status: 500, 57 | headers: { 58 | "Access-Control-Allow-Origin": "*", 59 | "Content-Type": "text/event-stream", 60 | "Cache-Control": "no-cache, no-transform", 61 | Connection: "keep-alive", 62 | }, 63 | }); 64 | } finally { 65 | controller.abort(); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/app/components/SpeechAnimation.css: -------------------------------------------------------------------------------- 1 | .circle { 2 | background-image: url("/profile-pic.png"); 3 | background-size: cover; 4 | background-position: center; 5 | background-color: white; 6 | border-radius: 50%; 7 | width: 300px; 8 | height: 300px; 9 | transition: transform 0.2s ease-in-out; 10 | } 11 | -------------------------------------------------------------------------------- /src/app/components/audio-widget.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { useState, useRef, useEffect } from "react"; 3 | import { useSpeechRecognition } from "../hooks/useSpeechRecognition"; 4 | import { processAudioStream } from "./utils/utils"; 5 | import SpeechAnimation from "./speech-animation"; 6 | import Controls from "./controls"; 7 | 8 | const AudioWidget: React.FC = () => { 9 | const [audioUrl, setAudioUrl] = useState(null); 10 | const audioRef = useRef(null); 11 | const [isPlaying, setIsPlaying] = useState(false); 12 | const [canPlay, setCanPlay] = useState(false); 13 | const { startRecognition, stopRecognition, transcript, volume } = 14 | useSpeechRecognition(); 15 | const [checkResult, setCheckResult] = useState(""); 16 | 17 | const checkSpeechRecognitionAndMicrophonePermission = async () => { 18 | if (!("webkitSpeechRecognition" in window)) { 19 | console.error( 20 | "Web Speech Recognition API is not available in this browser. " 21 | ); 22 | return "Web Speech Recognition API is not available in this browser. Please use Chrome"; 23 | } 24 | 25 | try { 26 | await navigator.mediaDevices.getUserMedia({ audio: true }); 27 | return "Web Speech Recognition API is available and Microphone has permission."; 28 | } catch (error) { 29 | console.error("Microphone permission has not been given:", error); 30 | return "Microphone permission has not been given:"; 31 | } 32 | }; 33 | useEffect(() => { 34 | checkSpeechRecognitionAndMicrophonePermission().then((result) => { 35 | setCheckResult(result); 36 | }); 37 | }, []); 38 | 39 | useEffect(() => { 40 | if (audioUrl) { 41 | setCanPlay(true); 42 | audioRef.current = new Audio(audioUrl); 43 | audioRef.current.addEventListener("play", () => { 44 | stopRecognition(); 45 | setIsPlaying(true); 46 | }); 47 | audioRef.current.addEventListener("pause", () => setIsPlaying(false)); 48 | audioRef.current.play(); 49 | } 50 | return () => { 51 | setCanPlay(false); 52 | if (audioRef.current) { 53 | audioRef.current.removeEventListener("play", () => setIsPlaying(true)); 54 | audioRef.current.removeEventListener("pause", () => 55 | setIsPlaying(false) 56 | ); 57 | audioRef.current.pause(); 58 | } 59 | }; 60 | }, [audioUrl]); 61 | 62 | useEffect(() => { 63 | const postTranscript = async () => { 64 | if (transcript) { 65 | try { 66 | processAudioStream(transcript, setAudioUrl); 67 | } catch (error) { 68 | console.error("Error processing audio:", error); 69 | } 70 | } 71 | }; 72 | 73 | postTranscript(); 74 | }, [transcript]); 75 | 76 | return ( 77 |
78 |
79 |

Tying Shoelaces Audio Demo

80 |

{checkResult &&

{checkResult}

}

81 |
82 | 83 |
87 | 88 | {!isPlaying ? ( 89 |

90 | Click on Ed to start recording. Recording will stop when speech 91 | stops. 92 |

93 | ) : ( 94 |

95 | Doing magical things with your audio... 96 |

97 | )} 98 |
100 | audioRef.current?.play()} 102 | pausePlaying={() => audioRef.current?.pause()} 103 | canPlay={canPlay} 104 | isPlaying={isPlaying} 105 | /> 106 |
107 | ); 108 | }; 109 | export default AudioWidget; 110 | -------------------------------------------------------------------------------- /src/app/components/controls.tsx: -------------------------------------------------------------------------------- 1 | import { PlayIcon, PauseIcon, MicrophoneIcon } from "@heroicons/react/24/solid"; 2 | 3 | interface ControlsProps { 4 | isPlaying: boolean; 5 | startPlaying: () => void | Promise; 6 | pausePlaying: () => void | Promise; 7 | canPlay: boolean; 8 | } 9 | 10 | const Controls: React.FC = ({ 11 | isPlaying, 12 | startPlaying, 13 | pausePlaying, 14 | canPlay, 15 | }) => { 16 | return ( 17 |
18 | {isPlaying ? ( 19 |
20 | 24 |
25 | ) : ( 26 |
31 | {}} 33 | className="text-white h-6 w-6 cursor-pointer" 34 | /> 35 |
36 | )} 37 |
38 | 39 |
40 |
46 |
52 |
58 |
64 |
65 |
66 |
67 | ); 68 | }; 69 | 70 | export default Controls; 71 | -------------------------------------------------------------------------------- /src/app/components/speech-animation.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./SpeechAnimation.css"; 3 | 4 | interface SpeechAnimationProps { 5 | volume: number; 6 | onClick?: () => void; 7 | } 8 | 9 | const SpeechAnimation: React.FC = ({ 10 | volume, 11 | onClick, 12 | }) => { 13 | const size = volume < 20 ? 50 : Math.min(Math.max(volume, 10), 100); 14 | 15 | return ( 16 |
21 | ); 22 | }; 23 | 24 | export default SpeechAnimation; 25 | -------------------------------------------------------------------------------- /src/app/components/utils/utils.ts: -------------------------------------------------------------------------------- 1 | // API endpoints 2 | const SUBSCRIBE_API = "/api/v1/groq"; 3 | const STREAM_API = "/api/v1/deepgram"; 4 | 5 | // Headers 6 | const JSON_HEADER = { "Content-Type": "application/json" }; 7 | const TEXT_HEADER = { "Content-Type": "text/plain" }; 8 | 9 | /** 10 | * Sends a POST request to the subscribe API with the given transcript. 11 | */ 12 | export async function postSubscribe(transcript: string): Promise { 13 | const response = await fetch(SUBSCRIBE_API, { 14 | method: "POST", 15 | headers: TEXT_HEADER, 16 | body: transcript, 17 | }); 18 | return response; 19 | } 20 | 21 | /** 22 | * Sends a POST request to the stream API with the given content. 23 | */ 24 | export async function postStream(content: string): Promise { 25 | const parsedContent = JSON.stringify(content.slice(0, 1000)); 26 | const response = await fetch(STREAM_API, { 27 | method: "POST", 28 | headers: JSON_HEADER, 29 | body: parsedContent, 30 | }); 31 | if (!response.ok) { 32 | throw new Error("Network response was not ok"); 33 | } 34 | 35 | return response.blob(); 36 | } 37 | 38 | /** 39 | * Extracts a JSON string from the given message. 40 | */ 41 | export function extractJson(message: string): string { 42 | const start = message.indexOf("{"); 43 | const end = message.lastIndexOf("}") + 1; 44 | return message.substring(start, end); 45 | } 46 | 47 | /** 48 | * Processes the audio stream for the given transcript. 49 | */ 50 | export const processAudioStream = async ( 51 | transcript: string, 52 | setAudioUrl: (url: string | null) => void 53 | ): Promise => { 54 | try { 55 | const response = await postSubscribe(transcript); 56 | if (!response.ok) { 57 | throw new Error(`HTTP error! status: ${response.status}`); 58 | } 59 | 60 | if (response.body) { 61 | const reader = response.body.getReader(); 62 | const decoder = new TextDecoder("utf-8"); 63 | 64 | while (true) { 65 | const { done, value } = await reader.read(); 66 | if (done) { 67 | console.log("Stream complete"); 68 | break; 69 | } 70 | 71 | const text = decoder.decode(value); 72 | const messages = text.split("\n\n"); 73 | 74 | for (const message of messages) { 75 | const jsonData = extractJson(message); 76 | if (jsonData) { 77 | const data = JSON.parse(jsonData); 78 | const audioBlob = await postStream(data.message); 79 | const audioUrl = URL.createObjectURL(audioBlob); 80 | console.log("Audio URL:", audioUrl); 81 | setAudioUrl(audioUrl); 82 | } 83 | } 84 | } 85 | } 86 | } catch (error) { 87 | console.error("Error processing audio:", error); 88 | } 89 | }; 90 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ejb503/ai-voice-generation/cd57b57100d24ff6e57ff1afdca8a24b7be46564/src/app/favicon.ico -------------------------------------------------------------------------------- /src/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 | -------------------------------------------------------------------------------- /src/app/hooks/useSpeechRecognition.ts: -------------------------------------------------------------------------------- 1 | import { useState, useCallback, useRef } from "react"; 2 | import { updateVolume } from "./utils/utils"; 3 | 4 | declare global { 5 | interface Window { 6 | webkitSpeechRecognition: any; 7 | } 8 | } 9 | 10 | export const useSpeechRecognition = () => { 11 | const [recognition, setRecognition] = useState( 12 | null 13 | ); 14 | const [transcript, setTranscript] = useState(null); 15 | const [volume, setVolume] = useState(0); 16 | const mediaStream = useRef(null); 17 | 18 | const startRecognition = useCallback(() => { 19 | if ("webkitSpeechRecognition" in window) { 20 | navigator.mediaDevices 21 | .getUserMedia({ audio: true, video: false }) 22 | .then((stream) => { 23 | mediaStream.current = stream; 24 | const recognition = new webkitSpeechRecognition(); 25 | recognition.lang = "en-US"; 26 | recognition.onresult = (event) => { 27 | if (event.results.length > 0) { 28 | const result = event.results[event.results.length - 1]; 29 | if (result.isFinal) { 30 | setTranscript(result[0].transcript); 31 | } 32 | } 33 | }; 34 | recognition.onspeechend = () => { 35 | recognition.stop(); 36 | setRecognition(null); 37 | }; 38 | recognition.start(); 39 | setTranscript(null); 40 | setRecognition(recognition); 41 | 42 | const audioContext = new AudioContext(); 43 | const source = audioContext.createMediaStreamSource(stream); 44 | const analyser = audioContext.createAnalyser(); 45 | analyser.fftSize = 256; 46 | source.connect(analyser); 47 | const bufferLength = analyser.frequencyBinCount; 48 | const dataArray = new Uint8Array(bufferLength); 49 | 50 | updateVolume(analyser, dataArray, bufferLength, setVolume); 51 | setTimeout(() => { 52 | setTranscript( 53 | "We didnt hear you properly. Try again and speak more clearly!!" 54 | ); 55 | if (recognition) { 56 | recognition.stop(); 57 | setRecognition(null); 58 | } 59 | }, 7500); 60 | }) 61 | .catch((err) => { 62 | console.error("Error accessing microphone:", err); 63 | }); 64 | } 65 | }, []); 66 | 67 | const stopRecognition = () => { 68 | if (recognition) { 69 | recognition.stop(); 70 | } 71 | if (mediaStream.current) { 72 | mediaStream.current.getTracks().forEach((track) => track.stop()); 73 | } 74 | }; 75 | 76 | return { startRecognition, stopRecognition, transcript, volume }; 77 | }; 78 | -------------------------------------------------------------------------------- /src/app/hooks/utils/utils.ts: -------------------------------------------------------------------------------- 1 | export const updateVolume = ( 2 | analyser: AnalyserNode, 3 | dataArray: Uint8Array, 4 | bufferLength: number, 5 | setVolume: (volume: number) => void 6 | ) => { 7 | analyser.getByteFrequencyData(dataArray); 8 | const volume = dataArray.reduce((a, b) => a + b) / bufferLength; 9 | setVolume(volume); 10 | requestAnimationFrame(() => 11 | updateVolume(analyser, dataArray, bufferLength, setVolume) 12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Inter } from "next/font/google"; 3 | import "./globals.css"; 4 | 5 | const inter = Inter({ subsets: ["latin"] }); 6 | 7 | export const metadata: Metadata = { 8 | title: "Create Next App", 9 | description: "Generated by create next app", 10 | }; 11 | 12 | export default function RootLayout({ 13 | children, 14 | }: Readonly<{ 15 | children: React.ReactNode; 16 | }>) { 17 | return ( 18 | 19 | {children} 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import AudioWidget from "./components/audio-widget"; 2 | 3 | export default function Home() { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config: Config = { 4 | content: [ 5 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 8 | ], 9 | theme: { 10 | extend: { 11 | backgroundImage: { 12 | "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", 13 | "gradient-conic": 14 | "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", 15 | }, 16 | }, 17 | }, 18 | plugins: [], 19 | }; 20 | export default config; 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./src/*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | --------------------------------------------------------------------------------