Shivam Mishra
2024-10-02 13:06:30 +05:30
committed by GitHub
parent e0bf2bd9d4
commit 42f6621afb
661 changed files with 15939 additions and 31194 deletions

View File

@@ -1,263 +1,116 @@
<script>
<script setup>
import getUuid from 'widget/helpers/uuid';
import 'video.js/dist/video-js.css';
import 'videojs-record/dist/css/videojs.record.css';
import videojs from 'video.js';
import { useAlert } from 'dashboard/composables';
import Recorder from 'opus-recorder';
// Workers to record Audio .ogg and .wav
import encoderWorker from 'opus-recorder/dist/encoderWorker.min';
import waveWorker from 'opus-recorder/dist/waveWorker.min';
import { ref, onMounted, onUnmounted, defineEmits, defineExpose } from 'vue';
import WaveSurfer from 'wavesurfer.js';
import MicrophonePlugin from 'wavesurfer.js/dist/plugin/wavesurfer.microphone.js';
import RecordPlugin from 'wavesurfer.js/dist/plugins/record.js';
import { format, intervalToDuration } from 'date-fns';
import { convertAudio } from './utils/mp3ConversionUtils';
import 'videojs-wavesurfer/dist/videojs.wavesurfer.js';
import 'videojs-record/dist/videojs.record.js';
import OpusRecorderEngine from 'videojs-record/dist/plugins/videojs.record.opus-recorder.js';
import { format, addSeconds } from 'date-fns';
import { AUDIO_FORMATS } from 'shared/constants/messages';
import { convertWavToMp3 } from './utils/mp3ConversionUtils';
WaveSurfer.microphone = MicrophonePlugin;
const RECORDER_CONFIG = {
[AUDIO_FORMATS.WAV]: {
audioMimeType: 'audio/wav',
audioWorkerURL: waveWorker,
},
[AUDIO_FORMATS.MP3]: {
audioMimeType: 'audio/wav',
audioWorkerURL: waveWorker,
},
[AUDIO_FORMATS.OGG]: {
audioMimeType: 'audio/ogg',
audioWorkerURL: encoderWorker,
const props = defineProps({
audioRecordFormat: {
type: String,
required: true,
},
});
const emit = defineEmits(['recorderProgressChanged', 'finishRecord']);
const waveformContainer = ref(null);
const wavesurfer = ref(null);
const record = ref(null);
const isRecording = ref(false);
const isPlaying = ref(false);
const hasRecording = ref(false);
const formatTimeProgress = time => {
const duration = intervalToDuration({ start: 0, end: time });
return format(
new Date(0, 0, 0, 0, duration.minutes, duration.seconds),
'mm:ss'
);
};
export default {
name: 'WootAudioRecorder',
props: {
audioRecordFormat: {
type: String,
default: AUDIO_FORMATS.WAV,
},
},
data() {
return {
player: false,
recordingDateStarted: new Date(0),
initialTimeDuration: '00:00',
recorderOptions: {
controls: true,
bigPlayButton: false,
fluid: false,
controlBar: {
deviceButton: false,
fullscreenToggle: false,
cameraButton: false,
volumePanel: false,
},
plugins: {
wavesurfer: {
backend: 'WebAudio',
waveColor: '#1f93ff',
progressColor: 'rgb(25, 118, 204)',
cursorColor: 'rgba(43, 51, 63, 0.7)',
backgroundColor: 'none',
barWidth: 1,
cursorWidth: 1,
hideScrollbar: true,
plugins: [
WaveSurfer.microphone.create({
bufferSize: 4096,
numberOfInputChannels: 1,
numberOfOutputChannels: 1,
constraints: {
video: false,
audio: true,
},
}),
],
},
record: {
audio: true,
video: false,
maxLength: 900,
timeSlice: 1000,
maxFileSize: 15 * 1024 * 1024,
displayMilliseconds: false,
audioChannels: 1,
audioSampleRate: 48000,
audioBitRate: 128,
audioEngine: 'opus-recorder',
...RECORDER_CONFIG[this.audioRecordFormat],
},
},
},
};
},
computed: {
isRecording() {
return this.player && this.player.record().isRecording();
},
},
mounted() {
window.Recorder = Recorder;
this.fireProgressRecord(this.initialTimeDuration);
this.player = videojs('#audio-wave', this.recorderOptions, () => {
this.$nextTick(() => {
this.player.record().getDevice();
});
const initWaveSurfer = () => {
wavesurfer.value = WaveSurfer.create({
container: waveformContainer.value,
waveColor: '#1F93FF',
progressColor: '#6E6F73',
height: 100,
barWidth: 2,
barGap: 1,
barRadius: 2,
plugins: [
RecordPlugin.create({
scrollingWaveform: true,
renderRecordedAudio: false,
}),
],
});
record.value = wavesurfer.value.plugins[0];
wavesurfer.value.on('finish', () => {
isPlaying.value = false;
});
record.value.on('record-end', async blob => {
const audioUrl = URL.createObjectURL(blob);
const audioBlob = await convertAudio(blob, props.audioRecordFormat);
const fileName = `${getUuid()}.mp3`;
const file = new File([audioBlob], fileName, {
type: props.audioRecordFormat,
});
this.player.on('deviceReady', this.deviceReady);
this.player.on('deviceError', this.deviceError);
this.player.on('startRecord', this.startRecord);
this.player.on('stopRecord', this.stopRecord);
this.player.on('progressRecord', this.progressRecord);
this.player.on('finishRecord', this.finishRecord);
this.player.on('playbackFinish', this.playbackFinish);
},
beforeDestroy() {
if (this.player) {
this.player.dispose();
}
if (window.Recorder) {
window.Recorder = undefined;
}
},
methods: {
deviceReady() {
if (this.player.record().engine instanceof OpusRecorderEngine) {
if (
[AUDIO_FORMATS.WAV, AUDIO_FORMATS.MP3].includes(
this.audioRecordFormat
)
) {
this.player.record().engine.audioType = 'audio/wav';
}
}
this.player.record().start();
},
startRecord() {
this.fireStateRecorderChanged('recording');
},
stopRecord() {
this.fireStateRecorderChanged('stopped');
},
async finishRecord() {
let recordedContent = this.player.recordedData;
let fileName = this.player.recordedData.name;
let type = this.player.recordedData.type;
if (this.audioRecordFormat === AUDIO_FORMATS.MP3) {
recordedContent = await convertWavToMp3(this.player.recordedData);
fileName = `${getUuid()}.mp3`;
type = AUDIO_FORMATS.MP3;
}
const file = new File([recordedContent], fileName, { type });
this.fireRecorderBlob(file);
},
progressRecord() {
this.fireProgressRecord(this.formatTimeProgress());
},
stopAudioRecording() {
this.player.record().stop();
},
deviceError() {
const deviceError = this.player.deviceErrorCode;
const deviceErrorName = deviceError?.name.toLowerCase();
if (
deviceErrorName?.includes('notallowederror') ||
deviceErrorName?.includes('permissiondeniederror')
) {
useAlert(this.$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_PERMISSION'));
this.fireStateRecorderChanged('notallowederror');
} else {
useAlert(this.$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_ERROR'));
}
},
formatTimeProgress() {
return format(
addSeconds(
new Date(this.recordingDateStarted.getTimezoneOffset() * 1000 * 60),
this.player.record().getDuration()
),
'mm:ss'
);
},
playPause() {
if (this.player.wavesurfer().surfer.isPlaying()) {
this.fireStateRecorderChanged('paused');
} else {
this.fireStateRecorderChanged('playing');
}
this.player.wavesurfer().surfer.playPause();
},
play() {
this.fireStateRecorderChanged('playing');
this.player.wavesurfer().play();
},
pause() {
this.fireStateRecorderChanged('paused');
this.player.wavesurfer().pause();
},
playbackFinish() {
this.fireStateRecorderChanged('paused');
this.player.wavesurfer().pause();
},
fireRecorderBlob(blob) {
this.$emit('finishRecord', {
name: blob.name,
type: blob.type,
size: blob.size,
file: blob,
});
},
fireStateRecorderChanged(state) {
this.$emit('stateRecorderChanged', state);
},
fireProgressRecord(duration) {
this.$emit('stateRecorderProgressChanged', duration);
},
},
wavesurfer.value.load(audioUrl);
emit('finishRecord', {
name: file.name,
type: file.type,
size: file.size,
file,
});
hasRecording.value = true;
isRecording.value = false;
});
record.value.on('record-progress', time => {
emit('recorderProgressChanged', formatTimeProgress(time));
});
};
const stopRecording = () => {
if (isRecording.value) {
record.value.stopRecording();
isRecording.value = false;
}
};
const startRecording = () => {
record.value.startRecording();
isRecording.value = true;
};
const playPause = () => {
if (hasRecording.value) {
wavesurfer.value.playPause();
isPlaying.value = !isPlaying.value;
}
};
onMounted(() => {
initWaveSurfer();
startRecording();
});
onUnmounted(() => {
if (wavesurfer.value) {
wavesurfer.value.destroy();
}
});
defineExpose({ playPause, stopRecording });
</script>
<template>
<div class="audio-wave-wrapper">
<audio id="audio-wave" class="video-js vjs-fill vjs-default-skin" />
<div class="w-full">
<div ref="waveformContainer" />
</div>
</template>
<style lang="scss">
.audio-wave-wrapper {
@apply h-20 min-h-[5rem];
.video-js {
@apply bg-transparent max-h-60 min-h-[3rem] pt-4 px-0 pb-0 resize-none;
}
}
// Added to override the default text and bg style to support dark and light mode.
.video-js .vjs-control-bar,
.vjs-record.video-js .vjs-control.vjs-record-indicator:before {
@apply text-slate-600 dark:text-slate-200 bg-transparent dark:bg-transparent;
}
// Added to fix div overlays the screen and takes over the button clicks
// https://github.com/collab-project/videojs-record/issues/688
// https://github.com/collab-project/videojs-record/pull/709
.vjs-record.video-js .vjs-control.vjs-record-indicator.vjs-hidden,
.vjs-record.video-js .vjs-control.vjs-record-indicator,
.vjs-record.video-js .vjs-control.vjs-record-indicator:before,
.vjs-record.video-js .vjs-control.vjs-record-indicator:after {
@apply pointer-events-none;
}
</style>

View File

@@ -36,10 +36,13 @@ const createState = (
});
};
let editorView = null;
let state;
export default {
mixins: [keyboardEventListenerMixins],
props: {
value: { type: String, default: '' },
modelValue: { type: String, default: '' },
editorId: { type: String, default: '' },
placeholder: { type: String, default: '' },
enabledMenuOptions: { type: Array, default: () => [] },
@@ -54,21 +57,19 @@ export default {
},
data() {
return {
editorView: null,
state: undefined,
plugins: [imagePastePlugin(this.handleImageUpload)],
};
},
computed: {
contentFromEditor() {
if (this.editorView) {
return ArticleMarkdownSerializer.serialize(this.editorView.state.doc);
if (editorView) {
return ArticleMarkdownSerializer.serialize(editorView.state.doc);
}
return '';
},
},
watch: {
value(newValue = '') {
modelValue(newValue = '') {
if (newValue !== this.contentFromEditor) {
this.reloadState();
}
@@ -79,8 +80,8 @@ export default {
},
created() {
this.state = createState(
this.value,
state = createState(
this.modelValue,
this.placeholder,
this.plugins,
{ onImageUpload: this.openFileBrowser },
@@ -90,7 +91,7 @@ export default {
mounted() {
this.createEditorView();
this.editorView.updateState(this.state);
editorView.updateState(state);
this.focusEditorInputField();
},
methods: {
@@ -145,53 +146,48 @@ export default {
}
},
onImageUploadStart(fileUrl) {
const { selection } = this.editorView.state;
const { selection } = editorView.state;
const from = selection.from;
const node = this.editorView.state.schema.nodes.image.create({
const node = editorView.state.schema.nodes.image.create({
src: fileUrl,
});
const paragraphNode = this.editorView.state.schema.node('paragraph');
const paragraphNode = editorView.state.schema.node('paragraph');
if (node) {
// Insert the image and the caption wrapped inside a paragraph
const tr = this.editorView.state.tr
const tr = editorView.state.tr
.replaceSelectionWith(paragraphNode)
.insert(from + 1, node);
this.editorView.dispatch(tr.scrollIntoView());
editorView.dispatch(tr.scrollIntoView());
this.focusEditorInputField();
}
},
reloadState() {
this.state = createState(
this.value,
state = createState(
this.modelValue,
this.placeholder,
this.plugins,
{ onImageUpload: this.openFileBrowser },
this.enabledMenuOptions
);
this.editorView.updateState(this.state);
editorView.updateState(state);
this.focusEditorInputField();
},
createEditorView() {
this.editorView = new EditorView(this.$refs.editor, {
state: this.state,
editorView = new EditorView(this.$refs.editor, {
state: state,
dispatchTransaction: tx => {
this.state = this.state.apply(tx);
this.emitOnChange();
state = state.apply(tx);
editorView.updateState(state);
if (tx.docChanged) {
this.emitOnChange();
}
},
handleDOMEvents: {
keyup: () => {
this.onKeyup();
},
keydown: (view, event) => {
this.onKeydown(event);
},
focus: () => {
this.onFocus();
},
blur: () => {
this.onBlur();
},
keyup: this.onKeyup,
focus: this.onFocus,
blur: this.onBlur,
keydown: this.onKeydown,
paste: (view, event) => {
const data = event.clipboardData.files;
if (data.length > 0) {
@@ -207,22 +203,18 @@ export default {
},
});
},
handleKeyEvents() {},
focusEditorInputField() {
const { tr } = this.editorView.state;
const { tr } = editorView.state;
const selection = Selection.atEnd(tr.doc);
this.editorView.dispatch(tr.setSelection(selection));
this.editorView.focus();
editorView.dispatch(tr.setSelection(selection));
editorView.focus();
},
emitOnChange() {
this.editorView.updateState(this.state);
this.$emit('update:modelValue', this.contentFromEditor);
this.$emit('input', this.contentFromEditor);
},
onKeyup() {
this.$emit('keyup');
},
@@ -255,7 +247,7 @@ export default {
</template>
<style lang="scss">
@import '~@chatwoot/prosemirror-schema/src/styles/article.scss';
@import '@chatwoot/prosemirror-schema/src/styles/article.scss';
.ProseMirror-menubar-wrapper {
display: flex;

View File

@@ -26,7 +26,7 @@ export default {
},
onSend: {
type: Function,
default: () => {},
default: () => { },
},
sendButtonText: {
type: String,
@@ -53,19 +53,19 @@ export default {
},
onFileUpload: {
type: Function,
default: () => {},
default: () => { },
},
toggleEmojiPicker: {
type: Function,
default: () => {},
default: () => { },
},
toggleAudioRecorder: {
type: Function,
default: () => {},
default: () => { },
},
toggleAudioRecorderPlayPause: {
type: Function,
default: () => {},
default: () => { },
},
isRecordingAudio: {
type: Boolean,
@@ -174,9 +174,8 @@ export default {
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.VOICE_RECORDER
) &&
this.showAudioRecorder &&
!isSafari
) && this.showAudioRecorder
// !isSafari
);
},
showAudioPlayStopButton() {

View File

@@ -1,6 +1,7 @@
<script>
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants';
export default {
name: 'ReplyTopPanel',
props: {
@@ -106,7 +107,7 @@ export default {
icon="dismiss"
color-scheme="secondary"
class-names="popout-button"
@click="$emit('click')"
@click="$emit('togglePopout')"
/>
<woot-button
v-else
@@ -114,7 +115,7 @@ export default {
icon="resize-large"
color-scheme="secondary"
class-names="popout-button"
@click="$emit('click')"
@click="$emit('togglePopout')"
/>
</div>
</template>

View File

@@ -10,7 +10,7 @@ const props = defineProps({
},
});
const emit = defineEmits(['click']);
const emit = defineEmits(['selectEmoji']);
const allEmojis = shallowRef([]);
@@ -34,7 +34,7 @@ function loadEmojis() {
}
function handleMentionClick(item = {}) {
emit('click', item.emoji);
emit('selectEmoji', item.emoji);
}
onMounted(() => {

View File

@@ -1,14 +1,77 @@
import lamejs from 'lamejs';
import lamejs from '@breezystack/lamejs';
const writeString = (view, offset, string) => {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
};
const bufferToWav = async (buffer, numChannels, sampleRate) => {
const length = buffer.length * numChannels * 2;
const wav = new ArrayBuffer(44 + length);
const view = new DataView(wav);
// WAV Header
writeString(view, 0, 'RIFF');
view.setUint32(4, 36 + length, true);
writeString(view, 8, 'WAVE');
writeString(view, 12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, numChannels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * numChannels * 2, true);
view.setUint16(32, numChannels * 2, true);
view.setUint16(34, 16, true);
writeString(view, 36, 'data');
view.setUint32(40, length, true);
// WAV Data
const offset = 44;
for (let i = 0; i < buffer.length; i++) {
for (let channel = 0; channel < numChannels; channel++) {
const sample = Math.max(
-1,
Math.min(1, buffer.getChannelData(channel)[i])
);
view.setInt16(
offset + (i * numChannels + channel) * 2,
sample * 0x7fff,
true
);
}
}
return new Blob([wav], { type: 'audio/wav' });
};
const decodeAudioData = async audioBlob => {
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const arrayBuffer = await audioBlob.arrayBuffer();
const audioData = await audioContext.decodeAudioData(arrayBuffer);
return audioData;
};
export const convertToWav = async audioBlob => {
const audioBuffer = await decodeAudioData(audioBlob);
return bufferToWav(
audioBuffer,
audioBuffer.numberOfChannels,
audioBuffer.sampleRate
);
};
/**
* Encodes a mono channel audio stream to MP3 format.
* Encodes audio samples to MP3 format.
* @param {number} channels - Number of audio channels.
* @param {number} sampleRate - Sample rate in Hz.
* @param {Int16Array} samples - Audio samples to be encoded.
* @param {number} bitrate - MP3 bitrate (default: 128)
* @returns {Blob} - The MP3 encoded audio as a Blob.
*/
export const encodeToMP3 = (channels, sampleRate, samples) => {
export const encodeToMP3 = (channels, sampleRate, samples, bitrate = 128) => {
const outputBuffer = [];
const encoder = new lamejs.Mp3Encoder(channels, sampleRate, 128);
const encoder = new lamejs.Mp3Encoder(channels, sampleRate, bitrate);
const maxSamplesPerFrame = 1152;
for (let offset = 0; offset < samples.length; offset += maxSamplesPerFrame) {
@@ -30,24 +93,51 @@ export const encodeToMP3 = (channels, sampleRate, samples) => {
};
/**
* Converts a WAV audio Blob to an MP3 format Blob.
* @param {Blob} blob - The audio data in WAV format as a Blob.
* Converts an audio Blob to an MP3 format Blob.
* @param {Blob} audioBlob - The audio data as a Blob.
* @param {number} bitrate - MP3 bitrate (default: 128)
* @returns {Promise<Blob>} - A Blob containing the MP3 encoded audio.
*/
export const convertWavToMp3 = async blob => {
export const convertToMp3 = async (audioBlob, bitrate = 128) => {
try {
const audioBuffer = await blob.arrayBuffer();
const wavHeader = lamejs.WavHeader.readHeader(new DataView(audioBuffer));
const audioBuffer = await decodeAudioData(audioBlob);
const samples = new Int16Array(
audioBuffer,
wavHeader.dataOffset,
wavHeader.dataLen / 2
audioBuffer.length * audioBuffer.numberOfChannels
);
let offset = 0;
for (let i = 0; i < audioBuffer.length; i += 1) {
for (
let channel = 0;
channel < audioBuffer.numberOfChannels;
channel += 1
) {
const sample = Math.max(
-1,
Math.min(1, audioBuffer.getChannelData(channel)[i])
);
samples[offset] = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
offset += 1;
}
}
return encodeToMP3(
audioBuffer.numberOfChannels,
audioBuffer.sampleRate,
samples,
bitrate
);
return encodeToMP3(wavHeader.channels, wavHeader.sampleRate, samples);
} catch (error) {
// eslint-disable-next-line
console.log('Failed to convert WAV to MP3:', error);
throw new Error('Conversion from WAV to MP3 failed.');
throw new Error('Conversion to MP3 failed.');
}
};
export const convertAudio = async (inputBlob, outputFormat, bitrate = 128) => {
let audio;
if (outputFormat === 'audio/wav') {
audio = await convertToWav(inputBlob);
} else if (outputFormat === 'audio/mp3') {
audio = await convertToMp3(inputBlob, bitrate);
} else {
throw new Error('Unsupported output format');
}
return audio;
};