diff --git a/docusaurus/docs/React/components/message-input-components/audio-recorder.mdx b/docusaurus/docs/React/components/message-input-components/audio-recorder.mdx index 11803a087c..c3b2ef3d6d 100644 --- a/docusaurus/docs/React/components/message-input-components/audio-recorder.mdx +++ b/docusaurus/docs/React/components/message-input-components/audio-recorder.mdx @@ -51,6 +51,29 @@ The dialog can be customized by passing own component to `Channel` component con ``` +## Custom encoding + +By default, the recording is encoded into `audio/wav` format. In order to reduce the size and keep the inter-browser format compatibility, you can use an MP3 encoder that is based on [`lamejs` implementation](https://github.com/gideonstele/lamejs). Follow these steps to achieve the MP3 encoding capability. + +1. The library `@breezystack/lamejs` has to be installed as this is a peer dependency to `stream-chat-react`. + +```shell +npm install @breezystack/lamejs +``` + +```shell +yarn add @breezystack/lamejs +``` + +2. The MP3 encoder has to be imported separately as a plugin: + +```tsx +import { MessageInput } from 'stream-chat-react'; +import { encodeToMp3 } from 'stream-chat-react/mp3-encoder'; + +; +``` + ## Audio recorder states The `AudioRecorder` UI switches between the following states diff --git a/docusaurus/docs/React/release-guides/upgrade-to-v12.mdx b/docusaurus/docs/React/release-guides/upgrade-to-v12.mdx index 746a685668..233c460131 100644 --- a/docusaurus/docs/React/release-guides/upgrade-to-v12.mdx +++ b/docusaurus/docs/React/release-guides/upgrade-to-v12.mdx @@ -4,6 +4,48 @@ title: Upgrade to v12 keywords: [migration guide, upgrade, v12, breaking changes] --- +## Audio recordings transcoding + +Until now, the audio recordings were transcoded to `audio/mp3` format for inter-browser compatibility and size reduction. However, as of the v12, the MIME type `audio/wav` will be the default. The MP3 encoder use is opt-in from now on. + +:::important +**Action required**
+ +1. The library `@breezystack/lamejs` has to be installed as this is a peer dependency to `stream-chat-react`. + +```shell +npm install @breezystack/lamejs +``` + +```shell +yarn add @breezystack/lamejs +``` + +2. The MP3 encoder has to be imported separately as a plugin: + +```tsx +import { MessageInput } from 'stream-chat-react'; +import { encodeToMp3 } from 'stream-chat-react/mp3-encoder'; + +; +``` + +::: + +## EmojiPickerIcon extraction to emojis plugin + +The default `EmojiPickerIcon` has been moved to emojis plugin from which we already import `EmojiPicker` component. + +:::important +**Action required**
+In case you are importing `EmojiPickerIcon` in your code, make sure to adjust the import as follows: + +```tsx +import { EmojiPickerIcon } from 'stream-chat-react/emojis'; +``` + +::: + ## Removal of duplicate uploads state in MessageInput As of the version 12 of `stream-chat-react` the `MessageInputContext` will not expose the following state variables: @@ -138,6 +180,12 @@ Until now, it was possible to import two stylesheets as follows: import 'stream-chat-react/dist/css/v1/index.css'; ``` +Or + +``` +import 'stream-chat-react/dist/css/v2/index.css'; +``` + The legacy stylesheet has been removed from the SDK bundle, and therefore it is only possible to import one stylesheet from now on: ``` diff --git a/docusaurus/react-docusaurus-dontent-docs.plugin.js b/docusaurus/react-docusaurus-dontent-docs.plugin.js index 05562b7064..b41e426965 100644 --- a/docusaurus/react-docusaurus-dontent-docs.plugin.js +++ b/docusaurus/react-docusaurus-dontent-docs.plugin.js @@ -3,10 +3,12 @@ module.exports = { [ '@docusaurus/plugin-content-docs', { - lastVersion: 'current', + lastVersion: '11.x.x', versions: { current: { - label: 'v12', + banner: 'unreleased', + label: 'v12 (rc)', + path: 'v12', }, '11.x.x': { label: 'v11', diff --git a/package.json b/package.json index 9b12b88f81..c2093c0715 100644 --- a/package.json +++ b/package.json @@ -21,10 +21,16 @@ "default": "./dist/index.js" }, "./emojis": { - "types": "./dist/components/Emojis/index.d.ts", - "require": "./dist/components/Emojis/index.cjs.js", - "import": "./dist/components/Emojis/index.js", - "default": "./dist/components/Emojis/index.js" + "types": "./dist/plugins/Emojis/index.d.ts", + "require": "./dist/plugins/Emojis/index.cjs.js", + "import": "./dist/plugins/Emojis/index.js", + "default": "./dist/plugins/Emojis/index.js" + }, + "./mp3-encoder": { + "types": "./dist/plugins/encoders/mp3.d.ts", + "require": "./dist/plugins/encoders/mp3.cjs.js", + "import": "./dist/plugins/encoders/mp3.js", + "default": "./dist/plugins/encoders/mp3.js" }, "./dist/css/*": { "default": "./dist/css/*" @@ -60,7 +66,6 @@ ], "dependencies": { "@braintree/sanitize-url": "^6.0.4", - "@breezystack/lamejs": "^1.2.7", "@popperjs/core": "^2.11.5", "@react-aria/focus": "^3", "clsx": "^2.0.0", @@ -98,6 +103,7 @@ "mml-react": "^0.4.7" }, "peerDependencies": { + "@breezystack/lamejs": "^1.2.7", "@emoji-mart/data": "^1.1.0", "@emoji-mart/react": "^1.1.0", "emoji-mart": "^5.4.0", @@ -106,6 +112,9 @@ "stream-chat": "^8.33.1" }, "peerDependenciesMeta": { + "@breezystack/lamejs": { + "optional": true + }, "emoji-mart": { "optional": true }, @@ -131,6 +140,7 @@ "@babel/preset-env": "^7.12.7", "@babel/preset-react": "^7.23.3", "@babel/preset-typescript": "^7.12.7", + "@breezystack/lamejs": "^1.2.7", "@commitlint/cli": "^18.4.3", "@commitlint/config-conventional": "^18.4.3", "@emoji-mart/data": "^1.1.2", diff --git a/scripts/bundle.mjs b/scripts/bundle.mjs index 09f55b19fd..92754e15d5 100755 --- a/scripts/bundle.mjs +++ b/scripts/bundle.mjs @@ -8,13 +8,13 @@ import * as esbuild from 'esbuild'; const __dirname = dirname(fileURLToPath(import.meta.url)); const sdkEntrypoint = resolve(__dirname, '../src/index.ts'); -const emojiEntrypoint = resolve(__dirname, '../src/components/Emojis/index.ts'); +const emojiEntrypoint = resolve(__dirname, '../src/plugins/Emojis/index.ts'); +const mp3EncoderEntrypoint = resolve(__dirname, '../src/plugins/encoders/mp3.ts'); const outDir = resolve(__dirname, '../dist'); // Those dependencies are distributed as ES modules, and cannot be externalized // in our CJS bundle. We convert them to CJS and bundle them instead. const bundledDeps = [ - '@breezystack/lamejs', 'hast-util-find-and-replace', 'unist-builder', 'unist-util-visit', @@ -32,7 +32,7 @@ const deps = Object.keys({ const external = deps.filter((dep) => !bundledDeps.includes(dep)); const cjsBundleConfig = { - entryPoints: [sdkEntrypoint, emojiEntrypoint], + entryPoints: [sdkEntrypoint, emojiEntrypoint, mp3EncoderEntrypoint], bundle: true, format: 'cjs', platform: 'node', diff --git a/src/components/Emojis/index.ts b/src/components/Emojis/index.ts deleted file mode 100644 index d0d758ae02..0000000000 --- a/src/components/Emojis/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './EmojiPicker'; diff --git a/src/components/MediaRecorder/classes/MediaRecorderController.ts b/src/components/MediaRecorder/classes/MediaRecorderController.ts index 9d4a1205db..13533c52af 100644 --- a/src/components/MediaRecorder/classes/MediaRecorderController.ts +++ b/src/components/MediaRecorder/classes/MediaRecorderController.ts @@ -7,7 +7,7 @@ import { } from './AmplitudeRecorder'; import { BrowserPermission } from './BrowserPermission'; import { BehaviorSubject, Subject } from '../observable'; -import { transcode } from '../transcode'; +import { transcode, TranscoderConfig } from '../transcode'; import { resampleWaveformData } from '../../Attachment'; import { createFileFromBlobs, @@ -30,8 +30,6 @@ const RECORDED_MIME_TYPE_BY_BROWSER = { }, } as const; -export const POSSIBLE_TRANSCODING_MIME_TYPES = ['audio/wav', 'audio/mp3'] as const; - export const DEFAULT_MEDIA_RECORDER_CONFIG: MediaRecorderConfig = { mimeType: isSafari() ? RECORDED_MIME_TYPE_BY_BROWSER.audio.safari @@ -40,7 +38,6 @@ export const DEFAULT_MEDIA_RECORDER_CONFIG: MediaRecorderConfig = { export const DEFAULT_AUDIO_TRANSCODER_CONFIG: TranscoderConfig = { sampleRate: 16000, - targetMimeType: 'audio/mp3', } as const; const disposeOfMediaStream = (stream?: MediaStream) => { @@ -53,15 +50,6 @@ const disposeOfMediaStream = (stream?: MediaStream) => { const logError = (e?: Error) => e && console.error('[MEDIA RECORDER ERROR]', e); -type SupportedTranscodeMimeTypes = typeof POSSIBLE_TRANSCODING_MIME_TYPES[number]; - -export type TranscoderConfig = { - // defaults to 16000Hz - sampleRate: number; - // Defaults to audio/mp3; - targetMimeType: SupportedTranscodeMimeTypes; -}; - type MediaRecorderConfig = Omit & Required>; @@ -71,8 +59,12 @@ export type AudioRecorderConfig = { transcoderConfig: TranscoderConfig; }; +type PartialValues = { [P in keyof T]?: Partial }; + +export type CustomAudioRecordingConfig = PartialValues; + export type AudioRecorderOptions = { - config?: Partial; + config?: CustomAudioRecordingConfig; generateRecordingTitle?: (mimeType: string) => string; t?: TranslationContextValue['t']; }; @@ -135,9 +127,6 @@ export class MediaRecorderController< { ...config?.transcoderConfig }, DEFAULT_AUDIO_TRANSCODER_CONFIG, ); - if (!POSSIBLE_TRANSCODING_MIME_TYPES.includes(this.transcoderConfig.targetMimeType)) { - this.transcoderConfig.targetMimeType = DEFAULT_AUDIO_TRANSCODER_CONFIG.targetMimeType; - } const mediaType = getRecordedMediaTypeFromMimeType(this.mediaRecorderConfig.mimeType); if (!mediaType) { diff --git a/src/components/MediaRecorder/classes/__tests__/MediaRecorderController.test.js b/src/components/MediaRecorder/classes/__tests__/MediaRecorderController.test.js index e75b68a3c3..ce89e15930 100644 --- a/src/components/MediaRecorder/classes/__tests__/MediaRecorderController.test.js +++ b/src/components/MediaRecorder/classes/__tests__/MediaRecorderController.test.js @@ -1,5 +1,6 @@ import fixWebmDuration from 'fix-webm-duration'; import * as transcoder from '../../transcode'; +import * as wavTranscoder from '../../transcode/wav'; import { DEFAULT_AUDIO_TRANSCODER_CONFIG, DEFAULT_MEDIA_RECORDER_CONFIG, @@ -25,17 +26,19 @@ jest.mock('nanoid', () => ({ nanoid: () => nanoidMockValue, })); -jest.mock('fix-webm-duration', () => jest.fn((blob) => blob)); +jest + .spyOn(wavTranscoder, 'encodeToWaw') + .mockImplementation((file) => Promise.resolve(new Blob([file], { type: 'audio/wav' }))); + +const mp3EncoderMock = jest.fn((file) => Promise.resolve(new Blob([file], { type: 'audio/mp3' }))); -const transcodeSpy = jest - .spyOn(transcoder, 'transcode') - .mockImplementation((opts) => - Promise.resolve(new Blob([opts.blob], { type: opts.targetMimeType })), - ); +jest.mock('fix-webm-duration', () => jest.fn((blob) => blob)); jest.spyOn(audioSampling, 'resampleWaveformData').mockReturnValue(dataPoints); -jest.spyOn(reactFileUtils, 'createFileFromBlobs').mockReturnValue(fileMock); +const createFileFromBlobsSpy = jest + .spyOn(reactFileUtils, 'createFileFromBlobs') + .mockReturnValue(fileMock); const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); const expectRegistersError = async ({ action, controller, errorMsg, notificationMsg }) => { @@ -457,6 +460,12 @@ describe('MediaRecorderController', () => { ['transcodes', 'audio/webm'], ['transcodes', 'audio/ogg'], ])('%s recording of MIME type %s', async (_, mimeType) => { + const transcodeSpy = jest + .spyOn(transcoder, 'transcode') + .mockImplementation((opts) => + Promise.resolve(new Blob([opts.blob], { type: 'audio/wav' })), + ); + const controller = new MediaRecorderController({ config: { mediaRecorderConfig: { mimeType } }, }); @@ -467,12 +476,13 @@ describe('MediaRecorderController', () => { } else { expect(transcodeSpy).toHaveBeenCalledTimes(1); } + transcodeSpy.mockRestore(); }); it.each([ ['audio/mp4', 'audio/mp4'], - ['audio/mp3', 'audio/webm'], - ['audio/mp3', 'audio/ogg'], + ['audio/wav', 'audio/webm'], + ['audio/wav', 'audio/ogg'], ])( 'generates recording of MIME type %s for original recording of MIME type %s', async (targetMimeType, recordedMimeType) => { @@ -484,6 +494,9 @@ describe('MediaRecorderController', () => { new Blob(new Uint8Array(dataPoints), { type: recordedMimeType }), ]; controller.recordedChunkDurations = dataPoints.map((n) => n * 1000); + const recordedFile = new File(controller.recordedData, fileMock); + createFileFromBlobsSpy.mockReturnValue(recordedFile); + const recording = await controller.makeVoiceRecording(); expect(recording).toStrictEqual( @@ -492,16 +505,98 @@ describe('MediaRecorderController', () => { duration: dataPoints.reduce((acc, n) => acc + n), file_size: recordedChunkCount, localMetadata: { - file: fileMock, + file: recordedFile, id: nanoidMockValue, }, mime_type: targetMimeType, - title: fileMock.name, + title: recordedFile.name, type: RecordingAttachmentType.VOICE_RECORDING, waveform_data: dataPoints, }), ); + createFileFromBlobsSpy.mockReturnValue(fileMock); }, ); + + it.each([ + ['audio/mp3', 'audio/webm'], + ['audio/mp3', 'audio/ogg'], + ])( + 'executes the custom MP3 encoder for MIME type %s', + async (targetMimeType, recordedMimeType) => { + const controller = new MediaRecorderController({ + config: { + mediaRecorderConfig: { mimeType: recordedMimeType }, + transcoderConfig: { encoder: mp3EncoderMock }, + }, + }); + + controller.recordedData = [ + new Blob(new Uint8Array(dataPoints), { type: recordedMimeType }), + ]; + controller.recordedChunkDurations = dataPoints.map((n) => n * 1000); + const recordedFile = new File(controller.recordedData, fileMock); + createFileFromBlobsSpy.mockReturnValue(recordedFile); + + const recording = await controller.makeVoiceRecording(); + + expect(mp3EncoderMock).toHaveBeenCalledWith( + recordedFile, + DEFAULT_AUDIO_TRANSCODER_CONFIG.sampleRate, + ); + expect(recording).toStrictEqual( + expect.objectContaining({ + asset_url: fileObjectURL, + duration: dataPoints.reduce((acc, n) => acc + n), + file_size: recordedChunkCount, + localMetadata: { + file: recordedFile, + id: nanoidMockValue, + }, + mime_type: targetMimeType, + title: recordedFile.name, + type: RecordingAttachmentType.VOICE_RECORDING, + waveform_data: dataPoints, + }), + ); + createFileFromBlobsSpy.mockReturnValue(fileMock); + }, + ); + + it('does not executed custom encoder for MIME type audio/mp4', async () => { + const targetMimeType = 'audio/mp4'; + const recordedMimeType = 'audio/mp4'; + const controller = new MediaRecorderController({ + config: { + mediaRecorderConfig: { mimeType: recordedMimeType }, + transcoderConfig: { encoder: mp3EncoderMock }, + }, + }); + + controller.recordedData = [new Blob(new Uint8Array(dataPoints), { type: recordedMimeType })]; + controller.recordedChunkDurations = dataPoints.map((n) => n * 1000); + const recordedFile = new File(controller.recordedData, fileMock); + createFileFromBlobsSpy.mockReturnValue(recordedFile); + + const recording = await controller.makeVoiceRecording(); + + expect(mp3EncoderMock).not.toHaveBeenCalled(); + expect(recording).toStrictEqual( + expect.objectContaining({ + asset_url: fileObjectURL, + duration: dataPoints.reduce((acc, n) => acc + n), + file_size: recordedChunkCount, + localMetadata: { + file: recordedFile, + id: nanoidMockValue, + }, + mime_type: targetMimeType, + title: recordedFile.name, + type: RecordingAttachmentType.VOICE_RECORDING, + waveform_data: dataPoints, + }), + ); + createFileFromBlobsSpy.mockReturnValue(fileMock); + }); }); }); diff --git a/src/components/MediaRecorder/hooks/index.ts b/src/components/MediaRecorder/hooks/index.ts index f2b882e3b3..cc466b9969 100644 --- a/src/components/MediaRecorder/hooks/index.ts +++ b/src/components/MediaRecorder/hooks/index.ts @@ -1 +1 @@ -export type { CustomAudioRecordingConfig, RecordingController } from './useMediaRecorder'; +export type { RecordingController } from './useMediaRecorder'; diff --git a/src/components/MediaRecorder/hooks/useMediaRecorder.ts b/src/components/MediaRecorder/hooks/useMediaRecorder.ts index 6ff2966e4b..a1550d6334 100644 --- a/src/components/MediaRecorder/hooks/useMediaRecorder.ts +++ b/src/components/MediaRecorder/hooks/useMediaRecorder.ts @@ -1,12 +1,14 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { MessageInputContextValue, useTranslationContext } from '../../../context'; -import { AudioRecorderConfig, MediaRecorderController, MediaRecordingState } from '../classes'; +import { + CustomAudioRecordingConfig, + MediaRecorderController, + MediaRecordingState, +} from '../classes'; import type { LocalVoiceRecordingAttachment } from '../../MessageInput'; import type { DefaultStreamChatGenerics } from '../../../types'; -export type CustomAudioRecordingConfig = Partial; - export type RecordingController< StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics > = { diff --git a/src/components/MediaRecorder/index.ts b/src/components/MediaRecorder/index.ts index 9c40539fc4..e34b1df464 100644 --- a/src/components/MediaRecorder/index.ts +++ b/src/components/MediaRecorder/index.ts @@ -3,3 +3,4 @@ export * from './AudioRecorder'; export * from './hooks'; export { MediaRecordingState } from './classes/MediaRecorderController'; export { RecordingPermission } from './classes/BrowserPermission'; +export type { CustomAudioRecordingConfig } from './classes'; diff --git a/src/components/MediaRecorder/transcode/index.ts b/src/components/MediaRecorder/transcode/index.ts index af28daff16..4a4e670514 100644 --- a/src/components/MediaRecorder/transcode/index.ts +++ b/src/components/MediaRecorder/transcode/index.ts @@ -1,25 +1,29 @@ import { encodeToWaw } from './wav'; -import { encodeToMp3 } from './mp3'; import { createFileFromBlobs, getExtensionFromMimeType } from '../../ReactFileUtilities'; -type TranscodeParams = { - blob: Blob; +export type TranscoderConfig = { + // defaults to 16000Hz sampleRate: number; - targetMimeType: string; + // Custom encoder function that converts the recorded audio file into a blob with the desired MIME type + encoder?: (file: File, sampleRate: number) => Promise; }; -export const transcode = ({ blob, sampleRate, targetMimeType }: TranscodeParams): Promise => { - const file = createFileFromBlobs({ - blobsArray: [blob], - fileName: `audio_recording_${new Date().toISOString()}.${getExtensionFromMimeType(blob.type)}`, - mimeType: blob.type, - }); - - if (targetMimeType.match('audio/wav')) { - return encodeToWaw(file, sampleRate); - } - if (targetMimeType.match('audio/mp3')) { - return encodeToMp3(file, sampleRate); - } - return Promise.resolve(blob); +export type TranscodeParams = TranscoderConfig & { + blob: Blob; }; + +export const transcode = ({ + blob, + encoder = encodeToWaw, + sampleRate, +}: TranscodeParams): Promise => + encoder( + createFileFromBlobs({ + blobsArray: [blob], + fileName: `audio_recording_${new Date().toISOString()}.${getExtensionFromMimeType( + blob.type, + )}`, + mimeType: blob.type, + }), + sampleRate, + ); diff --git a/src/components/MessageInput/icons.tsx b/src/components/MessageInput/icons.tsx index 57b68c7069..8e7310066e 100644 --- a/src/components/MessageInput/icons.tsx +++ b/src/components/MessageInput/icons.tsx @@ -3,19 +3,6 @@ import { nanoid } from 'nanoid'; import { useTranslationContext } from '../../context/TranslationContext'; -export const EmojiPickerIcon = () => ( - - - - - -); - export const LoadingIndicatorIcon = ({ size = 20 }: { size?: number }) => { const id = useMemo(() => nanoid(), []); diff --git a/src/components/Emojis/EmojiPicker.tsx b/src/plugins/Emojis/EmojiPicker.tsx similarity index 98% rename from src/components/Emojis/EmojiPicker.tsx rename to src/plugins/Emojis/EmojiPicker.tsx index 9cdd146a98..4146e0e914 100644 --- a/src/components/Emojis/EmojiPicker.tsx +++ b/src/plugins/Emojis/EmojiPicker.tsx @@ -6,7 +6,7 @@ import Picker from '@emoji-mart/react'; import type { Options } from '@popperjs/core'; import { useMessageInputContext, useTranslationContext } from '../../context'; -import { EmojiPickerIcon } from '../MessageInput/icons'; +import { EmojiPickerIcon } from './icons'; const isShadowRoot = (node: Node): node is ShadowRoot => !!(node as ShadowRoot).host; diff --git a/src/plugins/Emojis/icons.tsx b/src/plugins/Emojis/icons.tsx new file mode 100644 index 0000000000..a71b506c8f --- /dev/null +++ b/src/plugins/Emojis/icons.tsx @@ -0,0 +1,14 @@ +import React from 'react'; + +export const EmojiPickerIcon = () => ( + + + + + +); diff --git a/src/plugins/Emojis/index.ts b/src/plugins/Emojis/index.ts new file mode 100644 index 0000000000..4929ed23a5 --- /dev/null +++ b/src/plugins/Emojis/index.ts @@ -0,0 +1,2 @@ +export * from './EmojiPicker'; +export { EmojiPickerIcon } from './icons'; diff --git a/src/components/MediaRecorder/transcode/mp3.ts b/src/plugins/encoders/mp3.ts similarity index 94% rename from src/components/MediaRecorder/transcode/mp3.ts rename to src/plugins/encoders/mp3.ts index ada9c310c6..749e755688 100644 --- a/src/components/MediaRecorder/transcode/mp3.ts +++ b/src/plugins/encoders/mp3.ts @@ -1,4 +1,7 @@ -import { renderAudio, toAudioBuffer } from './audioProcessing'; +import { + renderAudio, + toAudioBuffer, +} from '../../components/MediaRecorder/transcode/audioProcessing'; const ENCODING_BIT_RATE = 128; // kbps; const COUNT_SAMPLES_PER_ENCODED_BLOCK = 1152;