Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[#2744] support recording voice messages from airy inbox UI #2895

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
041647b
wip
AudreyKj Feb 22, 2022
537e462
recording wip
AudreyKj Feb 22, 2022
f4c27ba
recording wip
AudreyKj Feb 23, 2022
c6e066f
added audio component with canvas
AudreyKj Feb 24, 2022
16b36df
save audio recording
AudreyKj Feb 25, 2022
a750665
audio file uplaod
AudreyKj Feb 25, 2022
f7822c3
recording pause and resume working
AudreyKj Feb 25, 2022
802f8bc
cleanup
AudreyKj Feb 25, 2022
7ec3b37
cleanup
AudreyKj Feb 25, 2022
c9e24bd
save edits
AudreyKj Mar 1, 2022
5784bdf
audio recording wip
AudreyKj Mar 1, 2022
bf20b13
audio wip
AudreyKj Mar 1, 2022
d965889
recording wip - still send msg bug
AudreyKj Mar 1, 2022
3fd90de
recording wip
AudreyKj Mar 2, 2022
1d99ec2
added polyfill, refactored audioClip and updated svgs
AudreyKj Mar 2, 2022
62d2d5e
refactoring and polyfill fix
AudreyKj Mar 2, 2022
366c775
saving fixes
AudreyKj Mar 2, 2022
0037120
finalizing working version
AudreyKj Mar 3, 2022
1887df3
typing and code clean-up
AudreyKj Mar 3, 2022
facc2fb
refactoring and lint
AudreyKj Mar 3, 2022
a568310
refactoring and type fix
AudreyKj Mar 3, 2022
fa48205
fix bazel dependency
AudreyKj Mar 3, 2022
4fdb7db
fix responsive waveform
AudreyKj Mar 3, 2022
52c7eb6
fix window type
AudreyKj Mar 3, 2022
a7c95ac
fixing mediaRecorder ts error
AudreyKj Mar 3, 2022
1358472
fix mediarecorder error
AudreyKj Mar 3, 2022
1715698
last refactoring fix
AudreyKj Mar 4, 2022
7926c89
refactoring: small typing and svg fix
AudreyKj Mar 4, 2022
7f5b63d
small tooltip fix
AudreyKj Mar 4, 2022
ad82a2e
audioClip broken down
AudreyKj Mar 4, 2022
89a955c
refactoring audioclip component
AudreyKj Mar 7, 2022
f49a766
refactoring and removed logs
AudreyKj Mar 7, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions frontend/ui/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ ts_web_library(
"@npm//@types/react-dom",
"@npm//@types/react-redux",
"@npm//@types/lodash-es",
"@npm//@types/dom-mediacapture-record",
"@npm//lodash-es",
"@npm//react",
"@npm//react-router-dom",
Expand All @@ -39,6 +40,7 @@ ts_web_library(
"@npm//typesafe-actions",
"@npm//camelcase-keys",
"@npm//react-color",
"@npm//audio-recorder-polyfill",
],
)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import React, {useState, useEffect} from 'react';
import {WaveformAudio} from './WaveformAudio';
import {ReactComponent as Pause} from 'assets/images/icons/stopMedia.svg';
import styles from './index.module.scss';

declare global {
interface Window {
webkitAudioContext: typeof AudioContext;
}
}

type AudioStreamProps = {
audioStream: MediaStream;
pauseRecording: () => void;
};

export function AudioStream({audioStream, pauseRecording}: AudioStreamProps) {
const [dataArr, setDataArr] = useState<number[]>([0]);
let audioAnalyser;
let audioArr;
let updateAudioArrId;
let source;

useEffect(() => {
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
audioAnalyser = audioContext.createAnalyser();
audioAnalyser.minDecibels = -90;
audioAnalyser.maxDecibels = -10;
audioAnalyser.smoothingTimeConstant = 0.85;
audioArr = new Uint8Array(audioAnalyser.frequencyBinCount);

source = audioContext.createMediaStreamSource(audioStream);
source.connect(audioAnalyser);
updateAudioArrId = requestAnimationFrame(updateAudio);

return () => {
window.cancelAnimationFrame(updateAudioArrId);
audioAnalyser.disconnect();
source.disconnect();
};
}, []);

const updateAudio = () => {
audioAnalyser.getByteFrequencyData(audioArr);
setDataArr([...audioArr]);
updateAudioArrId = requestAnimationFrame(updateAudio);
};

return (
<div className={styles.container}>
<div className={styles.waveformContainer}>
<WaveformAudio audioData={dataArr} />
</div>

<button type="button" className={`${styles.audioButtons} ${styles.pauseButton}`} onClick={pauseRecording}>
<Pause />
</button>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import React, {useState, useEffect, useRef} from 'react';

type WaveformAudioProps = {
audioData: number[];
};

export function WaveformAudio({audioData}: WaveformAudioProps) {
const canvas = useRef(null);
const [context, setContext] = useState(null);
const [barWidth, setBarWidth] = useState(3);
const [barTotalCount, setBarTotalCount] = useState(57);
const maxFrequencyValue = 255;
const canvasHeight = 40;

useEffect(() => {
if (canvas && canvas.current) {
setResponsiveCanvas();
setContext(canvas.current.getContext('2d'));
canvas.current.style.width = '100%';
canvas.current.style.height = canvasHeight + 'px';
canvas.current.width = canvas.current.offsetWidth;
canvas.current.height = canvas.current.offsetHeight;
}
}, []);

useEffect(() => {
if (audioData && context) {
context.clearRect(0, 0, canvas.current.width, canvas.current.height);
visualizeAudioRecording();
}
}, [context, audioData]);

const setResponsiveCanvas = () => {
if (window.innerWidth >= 1800 && window.innerWidth < 2000) {
setBarTotalCount(72);
} else if (window.innerWidth >= 2000) {
setBarTotalCount(90);
setBarWidth(4);
}
};

const visualizeAudioRecording = () => {
const canvasHeight = canvas.current.height;
const singleBarSize = canvas.current.width / barTotalCount;

context.lineWidth = barWidth;
context.strokeStyle = '#1578D4'; //Airy blue
context.lineCap = 'round';

let x = barWidth * 2;
for (let i = 0; i < barTotalCount; i++) {
const freqHeight = (audioData[i] / maxFrequencyValue) * canvasHeight;
const baseHeight = canvasHeight / 8;
const yStartingPoint = canvasHeight / 2 - freqHeight / 2 - baseHeight / 2;
const yEndPoint = yStartingPoint + freqHeight + baseHeight;

context.beginPath();
context.moveTo(x, yStartingPoint);
context.lineTo(x, yEndPoint);
context.stroke();
x += singleBarSize;
}
};

return <canvas ref={canvas}></canvas>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
@import 'assets/scss/colors.scss';
@import 'assets/scss/fonts.scss';

.container {
width: 100%;
height: 100%;
position: relative;
display: flex;
align-items: center;
justify-content: flex-end;
}

.waveformContainer {
width: 100%;
display: flex;
align-items: center;
}

.loading {
margin: 4px 0;
}

.audioButtons {
width: 28px;
height: 24px;
display: flex;
justify-content: center;
align-items: center;
background-color: var(--color-airy-blue);
border-radius: 50%;
border: none;
cursor: pointer;

svg {
path {
fill: white;
}
}
}

.cancelButton {
margin-left: 18px;
margin-right: 6px;
}

.pauseButton {
margin-right: 10px;
margin-left: 6px;
}

@keyframes fadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}

.audioComponent {
margin: 4px 6px 4px 0;
animation: fadeIn 2.5s linear forwards;
}
190 changes: 190 additions & 0 deletions frontend/ui/src/pages/Inbox/MessageInput/AudioRecording/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import React, {useState, useEffect} from 'react';
import {AudioStream} from './AudioStream';
import {AudioClip, SimpleLoader} from 'components';
import {uploadMedia} from '../../../../services/mediaUploader';
import {ReactComponent as Cancel} from 'assets/images/icons/cancelCross.svg';
import AudioRecorder from 'audio-recorder-polyfill';
import mpegEncoder from 'audio-recorder-polyfill/mpeg-encoder';
import styles from './index.module.scss';

declare global {
interface Window {
webkitAudioContext: typeof AudioContext;
MediaRecorder: typeof MediaRecorder;
}
}

AudioRecorder.encoder = mpegEncoder;
AudioRecorder.prototype.mimeType = 'audio/mpeg';
window.MediaRecorder = AudioRecorder;

type AudioRecordingProps = {
fetchMediaRecorder: (mediaRecorder: MediaRecorder) => void;
isAudioRecordingPaused: (isPaused: boolean) => void;
setAudioRecordingPreviewLoading: React.Dispatch<React.SetStateAction<boolean>>;
getUploadedAudioRecordingFile: (fileUrl: string) => void;
audioRecordingResumed: boolean;
setAudioRecordingResumed: React.Dispatch<React.SetStateAction<boolean>>;
audioRecordingSent: boolean;
audioRecordingCanceledUpdate: (isCanceled: boolean) => void;
setErrorPopUp: React.Dispatch<React.SetStateAction<string>>;
};

export function AudioRecording({
fetchMediaRecorder,
isAudioRecordingPaused,
setAudioRecordingPreviewLoading,
getUploadedAudioRecordingFile,
audioRecordingResumed,
setAudioRecordingResumed,
audioRecordingSent,
audioRecordingCanceledUpdate,
setErrorPopUp,
}: AudioRecordingProps) {
const [audioStream, setAudioStream] = useState<MediaStream | null>(null);
const [mediaRecorder, setMediaRecorder] = useState<MediaRecorder | null>(null);
const [savedAudioRecording, setSavedAudioRecording] = useState<File | null>(null);
const [audioRecordingFileUploaded, setAudioRecordingFileUploaded] = useState<string | null>(null);
const [loading, setLoading] = useState(false);

useEffect(() => {
let abort = false;

const startVoiceRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
});
setAudioStream(stream);
} catch {
audioRecordingCanceledUpdate(true);
setErrorPopUp(
'Microphone access denied. Check your browser settings to make sure Airy has permission to access your microphone, and try again.'
);
}
};

if (!abort) {
startVoiceRecording();
}

return () => {
abort = true;
};
}, []);

useEffect(() => {
if (audioStream && !audioRecordingSent) {
const mediaRecorder = new MediaRecorder(audioStream);
setMediaRecorder(mediaRecorder);
fetchMediaRecorder(mediaRecorder);

mediaRecorder.start();

const audioChunks = [];

const getAudioFile = event => {
audioChunks.push(event.data);

const audioBlob = new Blob(audioChunks);

const file = new File(audioChunks, 'recording.mp3', {
type: audioBlob.type,
lastModified: Date.now(),
});

setSavedAudioRecording(file);
};

mediaRecorder.addEventListener('dataavailable', getAudioFile);

return () => {
mediaRecorder.removeEventListener('dataavailable', getAudioFile);
};
}
}, [audioStream]);

useEffect(() => {
if (savedAudioRecording && !audioRecordingSent) {
let isRequestAborted = false;

if (!isRequestAborted) {
setLoading(true);
uploadMedia(savedAudioRecording)
.then((response: {mediaUrl: string}) => {
setAudioRecordingFileUploaded(response.mediaUrl);
getUploadedAudioRecordingFile(response.mediaUrl);
setLoading(false);
})
.catch(() => {
setLoading(false);
cancelRecording();
setErrorPopUp('Failed to upload the audio recording. Please try again later.');
});
}
return () => {
isRequestAborted = true;
};
}
}, [savedAudioRecording, audioRecordingSent]);

useEffect(() => {
if (loading) {
setAudioRecordingPreviewLoading(true);
} else {
setAudioRecordingPreviewLoading(false);
}
}, [loading]);

useEffect(() => {
if (audioRecordingResumed && mediaRecorder) {
setAudioRecordingFileUploaded(null);
mediaRecorder.resume();
}
}, [audioRecordingResumed, mediaRecorder]);

useEffect(() => {
if (audioRecordingSent) {
cancelRecording();
}
}, [audioRecordingSent]);

const pauseRecording = () => {
mediaRecorder.requestData();
mediaRecorder.pause();
isAudioRecordingPaused(true);
setAudioRecordingResumed(false);
};

const cancelRecording = () => {
setAudioRecordingFileUploaded(null);

mediaRecorder.stop();
mediaRecorder.stream.getTracks()[0].stop();

setAudioStream(null);
audioRecordingCanceledUpdate(true);
};

return (
<div className={`${styles.container} ${loading ? styles.loading : ''}`}>
{!loading && (
<button type="button" className={`${styles.audioButtons} ${styles.cancelButton}`} onClick={cancelRecording}>
<Cancel />
</button>
)}

{!audioRecordingFileUploaded && !loading && audioStream && (
<AudioStream pauseRecording={pauseRecording} audioStream={audioStream} />
)}

{loading && <SimpleLoader />}

{audioRecordingFileUploaded && (
<div className={styles.audioComponent}>
<AudioClip audioUrl={audioRecordingFileUploaded} />
</div>
)}
</div>
);
}