feat: Vite + vue 3 💚 (#10047)
Fixes https://github.com/chatwoot/chatwoot/issues/8436 Fixes https://github.com/chatwoot/chatwoot/issues/9767 Fixes https://github.com/chatwoot/chatwoot/issues/10156 Fixes https://github.com/chatwoot/chatwoot/issues/6031 Fixes https://github.com/chatwoot/chatwoot/issues/5696 Fixes https://github.com/chatwoot/chatwoot/issues/9250 Fixes https://github.com/chatwoot/chatwoot/issues/9762 --------- Co-authored-by: Pranav <pranavrajs@gmail.com> Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user