> Reopened from #13613, now from a personal fork (`gabrieljablonski/chatwoot`) so maintainers can push edits — organization-owned forks don't support "Allow edits from maintainers". The previous PR is closed in favor of this one; same commits, same diff. ## Description This PR adds support for sending voice messages (voice notes) through the WhatsApp Cloud API. When agents record audio in Chatwoot, it is now transcoded in the browser from WebM/Opus to OGG/Opus and sent with the `voice: true` flag, so it appears as a native voice note bubble on WhatsApp — not as a file/document attachment. Closes #13283 **Key Changes:** - Added `webmOpusToOgg.js` — a pure JS EBML parser + OGG page builder that remuxes browser-recorded WebM/Opus audio into OGG/Opus entirely client-side, with no server-side dependencies. - Updated `AudioRecorder.vue` to use an explicit `mimeType` hint, proper resource cleanup, and an `AUDIO_EXTENSION_MAP` for correct file extensions. - Renamed `mp3ConversionUtils.js` → `audioConversionUtils.js` and added OGG conversion support via the new remuxer. - Updated `ReplyBox.vue` to request OGG format for WhatsApp channels, pass `isVoiceMessage` per-attachment, and handle recording errors with a user-facing alert. - Updated `MessageBuilder` to read the `is_voice_message` param and persist it in attachment metadata. - Updated `WhatsappCloudService` to: - Normalize `audio/opus` → `audio/ogg` content type on ActiveStorage blobs (works around Marcel gem re-detection). - Send the `voice: true` flag when the attachment is a voice message with `audio/ogg` content type. - Use WhatsApp Cloud API `v24.0` for the attachment endpoint. - Added `AUDIO_CONVERSION_FAILED` i18n key. **How it works:** 1. The browser records audio as WebM/Opus (Chrome/Firefox default). 2. `audioConversionUtils.js` remuxes it to OGG/Opus using the pure-JS `webmOpusToOgg` remuxer — no server transcoding needed. 3. The OGG file is uploaded with `is_voice_message: true` in the form payload. 4. `MessageBuilder` persists `is_voice_message` in the attachment's `meta` hash. 5. `WhatsappCloudService` normalizes the blob content type if needed, then sends the attachment with `voice: true` so WhatsApp renders it as a voice note. ## Type of change - [X] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? 1. Record a voice message in a WhatsApp Cloud conversation. 2. Verify the audio is transcoded to OGG (check file extension in the attachment preview). 3. Verify the message arrives on WhatsApp as a voice note bubble (not a document/file). 4. Send an image or document attachment and verify it still works as before (no `voice` flag). 5. Send a regular (non-voice) audio file and verify it arrives without the voice flag. --------- Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com> Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
163 lines
5.1 KiB
JavaScript
163 lines
5.1 KiB
JavaScript
import lamejs from '@breezystack/lamejs';
|
|
|
|
import { remuxWebmToOgg } from './webmOpusToOgg';
|
|
|
|
const writeString = (view, offset, string) => {
|
|
// eslint-disable-next-line no-plusplus
|
|
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;
|
|
// eslint-disable-next-line no-plusplus
|
|
for (let i = 0; i < buffer.length; i++) {
|
|
// eslint-disable-next-line no-plusplus
|
|
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 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, bitrate = 128) => {
|
|
const outputBuffer = [];
|
|
const encoder = new lamejs.Mp3Encoder(channels, sampleRate, bitrate);
|
|
const maxSamplesPerFrame = 1152;
|
|
|
|
for (let offset = 0; offset < samples.length; offset += maxSamplesPerFrame) {
|
|
const sliceEnd = Math.min(offset + maxSamplesPerFrame, samples.length);
|
|
const sampleSlice = samples.subarray(offset, sliceEnd);
|
|
const mp3Buffer = encoder.encodeBuffer(sampleSlice);
|
|
|
|
if (mp3Buffer.length > 0) {
|
|
outputBuffer.push(new Int8Array(mp3Buffer));
|
|
}
|
|
}
|
|
|
|
const remainingData = encoder.flush();
|
|
if (remainingData.length > 0) {
|
|
outputBuffer.push(new Int8Array(remainingData));
|
|
}
|
|
|
|
return new Blob(outputBuffer, { type: 'audio/mp3' });
|
|
};
|
|
|
|
/**
|
|
* 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 convertToMp3 = async (audioBlob, bitrate = 128) => {
|
|
try {
|
|
const audioBuffer = await decodeAudioData(audioBlob);
|
|
const samples = new Int16Array(
|
|
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
|
|
);
|
|
} catch (error) {
|
|
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 if (outputFormat === 'audio/ogg') {
|
|
const inputType = inputBlob.type.split(';')[0].trim();
|
|
if (inputType === 'audio/webm' || inputType === 'video/webm') {
|
|
audio = await remuxWebmToOgg(inputBlob);
|
|
} else if (inputType === 'audio/ogg') {
|
|
audio = inputBlob;
|
|
} else {
|
|
// Browsers that record neither WebM nor OGG (e.g. Safari records
|
|
// audio/mp4) cannot produce OGG/Opus. Fall back to MP3 so the recording
|
|
// still sends as a regular audio message instead of failing. The caller
|
|
// keys the voice-note flag off the returned blob type, so an MP3 result
|
|
// is never mislabeled as an OGG/Opus voice note.
|
|
audio = await convertToMp3(inputBlob, bitrate);
|
|
}
|
|
} else {
|
|
throw new Error('Unsupported output format');
|
|
}
|
|
return audio;
|
|
};
|