Skip to content

Commit

Permalink
Merge pull request #36 from Shryansh107/molecule/voiceRecorder
Browse files Browse the repository at this point in the history
Adds voice recorder molecule
  • Loading branch information
geeky-abhishek authored Apr 1, 2024
2 parents acd14a1 + f59e4ae commit 9abe35c
Show file tree
Hide file tree
Showing 11 changed files with 286 additions and 3 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"@testing-library/react": "^14.2.1",
"@testing-library/user-event": "^14.5.2",
"@types/jest": "^29.5.12",
"@types/node": "^20.11.27",
"@types/lodash": "^4.14.202",
"@types/react": "^18.2.56",
"@types/react-dom": "^18.2.19",
Expand Down
15 changes: 13 additions & 2 deletions src/components/index.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { useCallback } from "react";
import { Box, Container, IconButton } from "@mui/material";
import { OTPInput } from "../molecules/otp-input";
import { List } from "../molecules/list";
import { useMemo } from "react";
import ForumIcon from "@mui/icons-material/Forum";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import { useColorPalates } from "../molecules/theme-provider/hooks";

import VoiceRecorder from '../molecules/VoiceRecorder'
import Navbar from "../molecules/Navbar/index";


const Components = () => {
const theme = useColorPalates();
const sampleList = useMemo(
Expand Down Expand Up @@ -60,8 +60,12 @@ const Components = () => {
],
[theme?.primary?.light]
);
const setInputMsg = useCallback(()=>{
//message to be passed to VoiceRecorders
},[])
return (


<Box
minHeight="95vh" // Full viewport height
style={{ background: "lightgray" }}
Expand All @@ -72,6 +76,13 @@ const Components = () => {
<div className="mt-2 p-5 border">
<OTPInput separator="-" length={4} value="" onChange={() => null} />
</div>
<div className='mt-2 p-5 border'>
<VoiceRecorder
setInputMsg={setInputMsg}
tapToSpeak={false}
includeDiv={false}
/>
</div>
</Container>

<Container>
Expand Down
Binary file added src/molecules/VoiceRecorder/assets/error.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/molecules/VoiceRecorder/assets/process.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/molecules/VoiceRecorder/assets/startIcon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/molecules/VoiceRecorder/assets/stop.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
25 changes: 25 additions & 0 deletions src/molecules/VoiceRecorder/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"theme": {
"primaryColor": {
"value": "#ffa500",
"allowOverride": true
},
"secondaryColor": {
"value": "#1E232C",
"allowOverride": true
}
},
"component": {
"allowOverride": false,
"startIcon": "src/molecules/VoiceRecorder/assets/startIcon.png",
"stopIcon": "src/molecules/VoiceRecorder/assets/stop.gif",
"errorIcon": "src/molecules/VoiceRecorder/assets/error.gif",
"processingIcon": "src/molecules/VoiceRecorder/assets/process.gif",
"voiceMinDecibels":-35,
"delayBetweenDialogs": 2500,
"dialogMaxLength":60000,
"isRecording":false,
"recorderErrorMessage": "Your question was not recognised. Pls try speaking more clearly.",
"waitMessage": "Please wait while we process your request..."
}
}
225 changes: 225 additions & 0 deletions src/molecules/VoiceRecorder/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
import { useState } from 'react'
import styles from './styles.module.css'
import toast from 'react-hot-toast'
import config from './config.json'

interface VoiceRecorder {
setInputMsg: (msg: string) => void
tapToSpeak: boolean
includeDiv?: boolean
}

const VoiceRecorder: React.FC<VoiceRecorder> = ({
setInputMsg,
tapToSpeak,
includeDiv = false,
}) => {
const [mediaRecorder, setMediaRecorder] = useState<MediaRecorder | null>(null)
const [isErrorClicked, setIsErrorClicked] = useState(false)
const [recorderStatus, setRecorderStatus] = useState('idle')

const voiceMinDecibels: number = config.component.voiceMinDecibels
const delayBetweenDialogs: number = config.component.delayBetweenDialogs
const dialogMaxLength: number = config.component.dialogMaxLength
const [isRecording,setIsRecording] = useState(config.component.isRecording)

const startRecording = () => {
if(!isRecording){
setIsRecording(true)
record()
}
}

const stopRecording = () => {
if(isRecording){
if (mediaRecorder !== null) {
mediaRecorder.stop()
setIsRecording(false)
setMediaRecorder(null)
}
}
}

function record() {
navigator.mediaDevices.getUserMedia({ audio: true }).then((stream) => {
//start recording:
const recorder = new MediaRecorder(stream)
recorder.start()
setMediaRecorder(recorder)

//save audio chunks:
const audioChunks: BlobPart[] = []
recorder.addEventListener('dataavailable', (event) => {
audioChunks.push(event.data)
})

//analysis:
const audioContext = new AudioContext()
const audioStreamSource = audioContext.createMediaStreamSource(stream)
const analyser = audioContext.createAnalyser()
analyser.minDecibels = voiceMinDecibels
audioStreamSource.connect(analyser)
const bufferLength = analyser.frequencyBinCount
const domainData = new Uint8Array(bufferLength)

//loop:
let time: Date = new Date()
let startTime: number
let lastDetectedTime: number = time.getTime()
let anySoundDetected: boolean = false
const detectSound = () => {
//recording stopped by user:
if (!isRecording) return

time = new Date()
const currentTime = time.getTime()

//time out:
if (currentTime > startTime + dialogMaxLength) {
recorder.stop()
return
}

//a dialog detected:
if (
anySoundDetected === true &&
currentTime > lastDetectedTime + delayBetweenDialogs
) {
recorder.stop()
return
}

//check for detection:
analyser.getByteFrequencyData(domainData)
for (let i = 0; i < bufferLength; i++)
if (domainData[i] > 0) {
anySoundDetected = true
time = new Date()
lastDetectedTime = time.getTime()
}

//continue the loop:
window?.requestAnimationFrame(detectSound)
}
window?.requestAnimationFrame(detectSound)

//stop event:
recorder.addEventListener('stop', () => {
//stop all the tracks:
stream.getTracks().forEach((track) => track.stop())
if (!anySoundDetected) return

//send to server:
const audioBlob = new Blob(audioChunks, { type: 'audio/mp3' })
makeComputeAPICall(audioBlob)
})
})
}
const makeComputeAPICall = async (blob: Blob) => {
try {
setRecorderStatus('processing')
toast.success(`${config.component.waitMessage}`)
// Define the API endpoint and make api call here
if(blob){
//set api result in setInputMsg
setInputMsg('')
}

} catch (error) {
console.error(error)
setRecorderStatus('error')
toast.error(`${config.component.recorderErrorMessage}`)
// Set isErrorClicked to true when an error occurs
setIsErrorClicked(false)
setTimeout(() => {
// Check if the user has not clicked the error icon again
if (!isErrorClicked) {
setRecorderStatus('idle')
}
}, 2500)

}
}

return (
<div>
<div>
{mediaRecorder && mediaRecorder.state === 'recording' ? (
<div className={styles.center}>
<RecorderControl
icon={config.component.stopIcon}
onClick={stopRecording}
includeDiv={includeDiv}
/>
</div>
) : (
<div className={styles.center}>
{recorderStatus === 'processing' ? (
<RecorderControl icon={config.component.processingIcon} onClick={()=>{}} />
) : recorderStatus === 'error' ? (
<RecorderControl
icon={config.component.errorIcon}
onClick={() => {
setIsErrorClicked(true);
startRecording();
}}
includeDiv={includeDiv}
/>
) : (
<div className={styles.center}>
<RecorderControl
icon={config.component.startIcon}
onClick={() => {
setIsErrorClicked(true);
startRecording();
}}
includeDiv={includeDiv}
tapToSpeak={tapToSpeak}
/>
</div>
)}
</div>
)}
</div>
</div>
);
}

// includeDiv is being checked in render Function
const RecorderControl: React.FC<{
icon: string;
onClick?: () => void;
includeDiv?: boolean;
tapToSpeak?: boolean;
}> = ({ icon, onClick, includeDiv = true, tapToSpeak= false }) => {
const handleClick = () => {
if(onClick){
onClick();
}
};

return includeDiv ? (
<div className={styles.imgContainer}>
<img
src={icon}
alt='icon'
onClick={handleClick}
style={{ cursor: 'pointer', height: '40px', width: '40px' }}
/>
{tapToSpeak && (
<p style={{ color: 'black', fontSize: '12px', marginTop: '4px' }}>
{'label.tap_to_speak'}
</p>
)}
</div>
) : (
<img
src={icon}
alt='icon'
onClick={handleClick}
style={{ cursor: 'pointer', height: '40px', width: '40px' }}
/>
);
};

export default VoiceRecorder
20 changes: 20 additions & 0 deletions src/molecules/VoiceRecorder/styles.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
.center {
display: block;
height: 100%;
width: 100%;
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}

.imgContainer {
position: relative;
overflow: hidden;
margin: auto;
width: 4rem;
height: 4rem;
}
3 changes: 2 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
"compilerOptions": {
"types": [
"jest",
"@testing-library/jest-dom"
"@testing-library/jest-dom",
"node"
],
"esModuleInterop": true,
"target": "ES2020",
Expand Down

0 comments on commit 9abe35c

Please sign in to comment.