diff --git a/bigbluebutton-html5/imports/api/users-persistent-data/server/modifiers/addUserPersistentData.js b/bigbluebutton-html5/imports/api/users-persistent-data/server/modifiers/addUserPersistentData.js
index 71288d6ae551..e404a6fe7800 100644
--- a/bigbluebutton-html5/imports/api/users-persistent-data/server/modifiers/addUserPersistentData.js
+++ b/bigbluebutton-html5/imports/api/users-persistent-data/server/modifiers/addUserPersistentData.js
@@ -8,6 +8,7 @@ export default async function addUserPersistentData(user) {
sortName: String,
color: String,
speechLocale: String,
+ translationLocale: String,
mobile: Boolean,
breakoutProps: Object,
inactivityCheck: Boolean,
diff --git a/bigbluebutton-html5/imports/api/users/server/methods.js b/bigbluebutton-html5/imports/api/users/server/methods.js
index 3d64c05d56f7..4f2344903136 100644
--- a/bigbluebutton-html5/imports/api/users/server/methods.js
+++ b/bigbluebutton-html5/imports/api/users/server/methods.js
@@ -2,6 +2,7 @@ import { Meteor } from 'meteor/meteor';
import validateAuthToken from './methods/validateAuthToken';
import setEmojiStatus from './methods/setEmojiStatus';
import setSpeechLocale from './methods/setSpeechLocale';
+import setTranslationLocale from './methods/setTranslationLocale';
import setMobileUser from './methods/setMobileUser';
import assignPresenter from './methods/assignPresenter';
import changeRole from './methods/changeRole';
@@ -17,6 +18,7 @@ import setExitReason from './methods/setExitReason';
Meteor.methods({
setEmojiStatus,
setSpeechLocale,
+ setTranslationLocale,
setMobileUser,
assignPresenter,
changeRole,
diff --git a/bigbluebutton-html5/imports/api/users/server/methods/setTranslationLocale.js b/bigbluebutton-html5/imports/api/users/server/methods/setTranslationLocale.js
new file mode 100644
index 000000000000..4d1530326e45
--- /dev/null
+++ b/bigbluebutton-html5/imports/api/users/server/methods/setTranslationLocale.js
@@ -0,0 +1,22 @@
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+import updateTranslationLocale from '../modifiers/updateTranslationLocale';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+
+const LANGUAGES = Meteor.settings.public.app.audioCaptions.language.available;
+
+export default function setTranslationLocale(locale) {
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(locale, String);
+
+ if (LANGUAGES.includes(locale) || locale === '') {
+ updateTranslationLocale(meetingId, requesterUserId, locale);
+ }
+ } catch (err) {
+ Logger.error(`Exception while invoking method setTranslationLocale ${err.stack}`);
+ }
+}
diff --git a/bigbluebutton-html5/imports/api/users/server/modifiers/addUser.js b/bigbluebutton-html5/imports/api/users/server/modifiers/addUser.js
index b8a476989bb1..35a31ee6f108 100755
--- a/bigbluebutton-html5/imports/api/users/server/modifiers/addUser.js
+++ b/bigbluebutton-html5/imports/api/users/server/modifiers/addUser.js
@@ -44,6 +44,7 @@ export default async function addUser(meetingId, userData) {
meetingId,
sortName: lowercaseTrim(user.name),
speechLocale: '',
+ translationLocale: '',
mobile: false,
breakoutProps: {
isBreakoutUser: Meeting.meetingProp.isBreakout,
diff --git a/bigbluebutton-html5/imports/api/users/server/modifiers/updateTranslationLocale.js b/bigbluebutton-html5/imports/api/users/server/modifiers/updateTranslationLocale.js
new file mode 100644
index 000000000000..f5bc7cab18a7
--- /dev/null
+++ b/bigbluebutton-html5/imports/api/users/server/modifiers/updateTranslationLocale.js
@@ -0,0 +1,25 @@
+import Logger from '/imports/startup/server/logger';
+import Users from '/imports/api/users';
+
+export default function updateTranslationLocale(meetingId, userId, locale) {
+ const selector = {
+ meetingId,
+ userId,
+ };
+
+ const modifier = {
+ $set: {
+ translationLocale: locale,
+ },
+ };
+
+ try {
+ const numberAffected = Users.update(selector, modifier);
+
+ if (numberAffected) {
+ Logger.info(`Updated translation locale=${locale} userId=${userId} meetingId=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Updating translation locale: ${err}`);
+ }
+}
diff --git a/bigbluebutton-html5/imports/ui/components/actions-bar/component.jsx b/bigbluebutton-html5/imports/ui/components/actions-bar/component.jsx
index d0e1d262ff8a..89a134fd2f72 100755
--- a/bigbluebutton-html5/imports/ui/components/actions-bar/component.jsx
+++ b/bigbluebutton-html5/imports/ui/components/actions-bar/component.jsx
@@ -74,11 +74,7 @@ class ActionsBar extends PureComponent {
)
: null}
- { !deviceInfo.isMobile
- ? (
-
- )
- : null }
+
diff --git a/bigbluebutton-html5/imports/ui/components/audio/captions/button/component.jsx b/bigbluebutton-html5/imports/ui/components/audio/captions/button/component.jsx
index d87503540c56..72ad3b94dbb3 100644
--- a/bigbluebutton-html5/imports/ui/components/audio/captions/button/component.jsx
+++ b/bigbluebutton-html5/imports/ui/components/audio/captions/button/component.jsx
@@ -24,12 +24,22 @@ const intlMessages = defineMessages({
id: 'app.audio.captions.button.transcription',
description: 'Audio speech transcription label',
},
+ translation: {
+ id: 'app.audio.captions.button.translation',
+ description: 'Audio speech translation label',
+ },
transcriptionOn: {
id: 'app.switch.onLabel',
},
transcriptionOff: {
id: 'app.switch.offLabel',
},
+ translationOn: {
+ id: 'app.switch.onLabel',
+ },
+ translationOff: {
+ id: 'app.switch.offLabel',
+ },
language: {
id: 'app.audio.captions.button.language',
description: 'Audio speech recognition language label',
@@ -85,24 +95,39 @@ const CaptionsButton = ({
isRTL,
enabled,
currentSpeechLocale,
+ currentTranslationLocale,
availableVoices,
+ availableTranslations,
isSupported,
isVoiceUser,
}) => {
const isTranscriptionDisabled = () => (
currentSpeechLocale === DISABLED
);
+ const isTranslationDisabled = () => (
+ currentTranslationLocale === DISABLED
+ );
+
+ const isTranslationActivated = () => (
+ Meteor.settings.public.captions.enableAutomaticTranslation
+ );
const fallbackLocale = availableVoices.includes(navigator.language)
? navigator.language : DEFAULT_LOCALE;
const getSelectedLocaleValue = (isTranscriptionDisabled() ? fallbackLocale : currentSpeechLocale);
+ const getSelectedTranslationLocaleValue = (isTranslationDisabled() ? fallbackLocale : currentTranslationLocale);
const selectedLocale = useRef(getSelectedLocaleValue);
+ const selectedTranslationLocale = useRef(getSelectedTranslationLocaleValue);
useEffect(() => {
if (!isTranscriptionDisabled()) selectedLocale.current = getSelectedLocaleValue;
}, [currentSpeechLocale]);
+
+ useEffect(() => {
+ if (!isTranslationDisabled()) selectedTranslationLocale.current = getSelectedTranslationLocaleValue;
+ }, [currentTranslationLocale]);
if (!enabled) return null;
@@ -115,9 +140,8 @@ const CaptionsButton = ({
label: intl.formatMessage(intlMessages[availableVoice]),
key: availableVoice,
iconRight: selectedLocale.current === availableVoice ? 'check' : null,
- customStyles: (selectedLocale.current === availableVoice) && Styled.SelectedLabel,
+ customStyles: (selectedLocale.current === availableVoice) ? Styled.SelectedLabel : Styled.NormalLabel,
disabled: isTranscriptionDisabled(),
- dividerTop: availableVoice === availableVoices[0],
onClick: () => {
selectedLocale.current = availableVoice;
SpeechService.setSpeechLocale(selectedLocale.current);
@@ -125,39 +149,79 @@ const CaptionsButton = ({
}
))
);
+
+ const getAvailableTranslationLocales = () => (
+ availableTranslations.map((availableTranslation) => (
+ {
+ icon: '',
+ label: intl.formatMessage(intlMessages[availableTranslation]),
+ key: availableTranslation,
+ iconRight: selectedTranslationLocale.current === availableTranslation ? 'check' : null,
+ customStyles: (selectedTranslationLocale.current === availableTranslation) ? Styled.SelectedLabel : Styled.NormalLabel,
+ disabled: isTranscriptionDisabled() || isTranslationDisabled(),
+ onClick: () => {
+ selectedTranslationLocale.current = availableTranslation;
+ SpeechService.setTranslationLocale(selectedTranslationLocale.current);
+ },
+ }
+ ))
+ );
const toggleTranscription = () => {
SpeechService.setSpeechLocale(isTranscriptionDisabled() ? selectedLocale.current : DISABLED);
};
- const getAvailableLocalesList = () => (
- [{
- key: 'availableLocalesList',
- label: intl.formatMessage(intlMessages.language),
- customStyles: Styled.TitleLabel,
- disabled: true,
- dividerTop: false,
- },
- ...getAvailableLocales(),
- {
- key: 'divider',
- label: intl.formatMessage(intlMessages.transcription),
- customStyles: Styled.TitleLabel,
- disabled: true,
- }, {
- key: 'transcriptionStatus',
- label: intl.formatMessage(
- isTranscriptionDisabled()
- ? intlMessages.transcriptionOn
- : intlMessages.transcriptionOff,
- ),
- customStyles: isTranscriptionDisabled()
- ? Styled.EnableTrascription : Styled.DisableTrascription,
- disabled: false,
- dividerTop: true,
- onClick: toggleTranscription,
- }]
- );
+ const toggleTranslation = () => {
+ SpeechService.setTranslationLocale(isTranslationDisabled() ? selectedTranslationLocale.current : DISABLED);
+ };
+
+ const getAvailableLocalesList = () => {
+ let items = [{
+ key: 'divider',
+ label: intl.formatMessage(intlMessages.transcription),
+ customStyles: Styled.TitleLabel,
+ disabled: true,
+ },
+ ...getAvailableLocales(),
+ {
+ key: 'transcriptionStatus',
+ label: intl.formatMessage(
+ isTranscriptionDisabled()
+ ? intlMessages.transcriptionOn
+ : intlMessages.transcriptionOff,
+ ),
+ customStyles: isTranscriptionDisabled()
+ ? Styled.EnableTrascription : Styled.DisableTrascription,
+ disabled: false,
+ onClick: toggleTranscription,
+ }];
+
+ if (isTranslationActivated()) {
+ items = items.concat([
+ {
+ key: 'divider',
+ label: intl.formatMessage(intlMessages.translation),
+ customStyles: Styled.TitleLabel,
+ disabled: true,
+ dividerTop: true,
+ },
+ ...getAvailableTranslationLocales(),
+ {
+ key: 'translationStatus',
+ label: intl.formatMessage(
+ isTranslationDisabled()
+ ? intlMessages.translationOn
+ : intlMessages.translationOff,
+ ),
+ customStyles: isTranslationDisabled()
+ ? Styled.EnableTraslation : Styled.DisableTraslation,
+ disabled: isTranscriptionDisabled(),
+ onClick: toggleTranslation,
+ }
+ ]);
+ }
+ return items;
+ };
const onToggleClick = (e) => {
e.stopPropagation();
@@ -219,6 +283,7 @@ CaptionsButton.propTypes = {
isRTL: PropTypes.bool.isRequired,
enabled: PropTypes.bool.isRequired,
currentSpeechLocale: PropTypes.string.isRequired,
+ currentTranslationLocale: PropTypes.string.isRequired,
availableVoices: PropTypes.arrayOf(PropTypes.string).isRequired,
isSupported: PropTypes.bool.isRequired,
isVoiceUser: PropTypes.bool.isRequired,
diff --git a/bigbluebutton-html5/imports/ui/components/audio/captions/button/container.jsx b/bigbluebutton-html5/imports/ui/components/audio/captions/button/container.jsx
index 44a08b88c9a7..f9eb1caa14b8 100644
--- a/bigbluebutton-html5/imports/ui/components/audio/captions/button/container.jsx
+++ b/bigbluebutton-html5/imports/ui/components/audio/captions/button/container.jsx
@@ -10,7 +10,9 @@ const Container = (props) => ;
export default withTracker(() => {
const isRTL = document.documentElement.getAttribute('dir') === 'rtl';
const availableVoices = SpeechService.getSpeechVoices();
+ const availableTranslations = SpeechService.getTranslations();
const currentSpeechLocale = SpeechService.getSpeechLocale();
+ const currentTranslationLocale = SpeechService.getTranslationLocale();
const isSupported = availableVoices.length > 0;
const isVoiceUser = AudioService.isVoiceUser();
return {
@@ -18,7 +20,9 @@ export default withTracker(() => {
enabled: Service.hasAudioCaptions(),
active: Service.getAudioCaptions(),
currentSpeechLocale,
+ currentTranslationLocale,
availableVoices,
+ availableTranslations,
isSupported,
isVoiceUser,
};
diff --git a/bigbluebutton-html5/imports/ui/components/audio/captions/button/styles.js b/bigbluebutton-html5/imports/ui/components/audio/captions/button/styles.js
index d82aaf7f2c24..b1c5fc35cb82 100644
--- a/bigbluebutton-html5/imports/ui/components/audio/captions/button/styles.js
+++ b/bigbluebutton-html5/imports/ui/components/audio/captions/button/styles.js
@@ -32,6 +32,12 @@ const TranscriptionToggle = styled(Toggle)`
padding-left: 1em;
`;
+const TranslationToggle = styled(Toggle)`
+ display: flex;
+ justify-content: flex-start;
+ padding-left: 1em;
+`;
+
const TitleLabel = {
fontWeight: 'bold',
opacity: 1,
@@ -39,23 +45,44 @@ const TitleLabel = {
const EnableTrascription = {
color: colorSuccess,
+ textIndent: '1em',
};
const DisableTrascription = {
color: colorDangerDark,
+ textIndent: '1em',
+};
+
+const EnableTraslation = {
+ color: colorSuccess,
+ textIndent: '1em',
+};
+
+const DisableTraslation = {
+ color: colorDangerDark,
+ textIndent: '1em',
+};
+
+const NormalLabel = {
+ textIndent: '1em',
};
const SelectedLabel = {
color: colorPrimary,
backgroundColor: colorOffWhite,
+ textIndent: '1em',
};
export default {
ClosedCaptionToggleButton,
SpanButtonWrapper,
TranscriptionToggle,
+ TranslationToggle,
TitleLabel,
EnableTrascription,
DisableTrascription,
+ EnableTraslation,
+ DisableTraslation,
+ NormalLabel,
SelectedLabel,
};
diff --git a/bigbluebutton-html5/imports/ui/components/audio/captions/speech/service.js b/bigbluebutton-html5/imports/ui/components/audio/captions/speech/service.js
index 93d62e387011..8f462b7a813a 100644
--- a/bigbluebutton-html5/imports/ui/components/audio/captions/speech/service.js
+++ b/bigbluebutton-html5/imports/ui/components/audio/captions/speech/service.js
@@ -8,6 +8,7 @@ import Users from '/imports/api/users';
import AudioService from '/imports/ui/components/audio/service';
import deviceInfo from '/imports/utils/deviceInfo';
import { isLiveTranscriptionEnabled } from '/imports/ui/services/features';
+import axios from 'axios';
const THROTTLE_TIMEOUT = 1000;
@@ -15,6 +16,8 @@ const CONFIG = Meteor.settings.public.app.audioCaptions;
const LANGUAGES = CONFIG.language.available;
const VALID_ENVIRONMENT = !deviceInfo.isMobile || CONFIG.mobile;
+const CAPTIONS_CONFIG = Meteor.settings.public.captions;
+
const SpeechRecognitionAPI = window.SpeechRecognition || window.webkitSpeechRecognition;
const hasSpeechRecognitionSupport = () => typeof SpeechRecognitionAPI !== 'undefined'
@@ -27,6 +30,12 @@ const setSpeechVoices = () => {
Session.set('speechVoices', _.uniq(window.speechSynthesis.getVoices().map((v) => v.lang)));
};
+const setTranslations = () => {
+ if (!hasSpeechRecognitionSupport()) return;
+ //For now the same items as transcription, but they can be different and are dependent on translation services.
+ Session.set('translations', _.uniq(window.speechSynthesis.getVoices().map((v) => v.lang)));
+};
+
// Trigger getVoices
setSpeechVoices();
@@ -36,6 +45,12 @@ const getSpeechVoices = () => {
return voices.filter((v) => LANGUAGES.includes(v));
};
+const getTranslations = () => {
+ const voices = Session.get('translations') || [];
+
+ return voices.filter((v) => LANGUAGES.includes(v));
+};
+
const setSpeechLocale = (value) => {
const voices = getSpeechVoices();
if (voices.includes(value) || value === '') {
@@ -47,6 +62,17 @@ const setSpeechLocale = (value) => {
}
};
+const setTranslationLocale = (value) => {
+ const voices = getTranslations();
+ if (voices.includes(value) || value === '') {
+ makeCall('setTranslationLocale', value);
+ } else {
+ logger.error({
+ logCode: 'captions_translation_locale',
+ }, 'Captions translation set locale error');
+ }
+};
+
const useFixedLocale = () => isEnabled() && CONFIG.language.forceLocale;
const initSpeechRecognition = () => {
@@ -54,6 +80,7 @@ const initSpeechRecognition = () => {
if (hasSpeechRecognitionSupport()) {
// Effectivate getVoices
setSpeechVoices();
+ setTranslations();
const speechRecognition = new SpeechRecognitionAPI();
speechRecognition.continuous = true;
speechRecognition.interimResults = true;
@@ -75,15 +102,72 @@ const initSpeechRecognition = () => {
};
let prevId = '';
-let prevTranscript = '';
+const prevTranscripts = {};
const updateTranscript = (id, transcript, locale) => {
+ const translationLocale = getTranslationLocale();
// If it's a new sentence
if (id !== prevId) {
prevId = id;
- prevTranscript = '';
+ prevTranscripts[locale] = '';
+ prevTranscripts[translationLocale] = '';
}
- const transcriptDiff = diff(prevTranscript, transcript);
+ if ( CAPTIONS_CONFIG.enableAutomaticTranslation && translationLocale && translationLocale !== "" &&
+ locale !== translationLocale && locale.replace(/-..$/,'') !== translationLocale && locale !== translationLocale.replace(/-..$/,'') ) {
+ translateTranscript(id, transcript, locale, translationLocale);
+ }
+
+ sendDiffTranscript(id, transcript, locale);
+};
+
+const translateTranscript = (id, text, src, dst) => {
+ if (text.match(/\S/)) {
+ const textDecap = text.replace(/^\s*/, '');
+ const preSpace = text === textDecap ? '' : ' ';
+ //console.log("translateTranscript", "|"+text+"|,", "|"+preSpace+"|");
+ let url = '';
+ if (CAPTIONS_CONFIG.googleTranslateUrl) {
+ url = CAPTIONS_CONFIG.googleTranslateUrl + '/exec?' +
+ 'text=' + encodeURIComponent(textDecap) + '&source=' + src + '&target=' + dst.replace(/-..$/,'');
+ } else if (CAPTIONS_CONFIG.deeplTranslateUrl) {
+ url = CAPTIONS_CONFIG.deeplTranslateUrl +
+ '&text=' + encodeURIComponent(textDecap) + '&source_lang=' + src.replace(/-..$/,'').toUpperCase() +
+ '&target_lang=' + dst.replace(/-..$/,'').toUpperCase();
+ } else {
+ logger.error('Could not get a translation service.');
+ return;
+ }
+
+ axios({
+ method: 'get',
+ url,
+ responseType: 'json',
+ }).then((response) => {
+ if (CAPTIONS_CONFIG.googleTranslateUrl) {
+ const { code, text } = response.data;
+ if (code === 200) {
+ sendDiffTranscript(id, preSpace + text, dst);
+ } else {
+ logger.error(`Failed to get Google translation for "${transcriptOri}"`);
+ }
+ } else if (CAPTIONS_CONFIG.deeplTranslateUrl) {
+ const { translations } = response.data;
+ if (translations.length > 0 && translations[0].text) {
+ sendDiffTranscript(id, preSpace + translations[0].text, dst);
+ } else {
+ logger.error(`Failed to get DeepL translation for "${transcriptOri}"`);
+ }
+ }
+ }).catch((error) => logger.error(`Could not get translation for ${transcriptOri.trim()} on the locale ${dst}: ${error}`));
+
+ } else {
+ sendDiffTranscript(id, text, dst);
+ }
+}
+
+const sendDiffTranscript = (id, tsc, loc) => {
+ const transcriptDiff = diff(prevTranscripts[loc], tsc);
+ //console.log("sendDiffTranscript", loc, "\np:", prevTranscripts[loc], "\nc:", tsc, "\n", transcriptDiff);
let start = 0;
let end = 0;
@@ -95,9 +179,9 @@ const updateTranscript = (id, transcript, locale) => {
}
// Stores current transcript as previous
- prevTranscript = transcript;
+ prevTranscripts[loc] = tsc;
- makeCall('updateTranscript', id, start, end, text, transcript, locale);
+ makeCall('updateTranscript', id, start, end, text, tsc, loc);
};
const throttledTranscriptUpdate = _.throttle(updateTranscript, THROTTLE_TIMEOUT, {
@@ -122,6 +206,14 @@ const getSpeechLocale = (userId = Auth.userID) => {
return '';
};
+const getTranslationLocale = (userId = Auth.userID) => {
+ const user = Users.findOne({ userId }, { fields: { translationLocale: 1 } });
+
+ if (user) return user.translationLocale;
+
+ return '';
+};
+
const hasSpeechLocale = (userId = Auth.userID) => getSpeechLocale(userId) !== '';
const isLocaleValid = (locale) => LANGUAGES.includes(locale);
@@ -162,8 +254,11 @@ export default {
updateInterimTranscript,
updateFinalTranscript,
getSpeechVoices,
+ getTranslations,
getSpeechLocale,
+ getTranslationLocale,
setSpeechLocale,
+ setTranslationLocale,
hasSpeechLocale,
isLocaleValid,
isEnabled,
diff --git a/bigbluebutton-html5/private/config/settings.yml b/bigbluebutton-html5/private/config/settings.yml
index 6235069816e8..6e043bf5b2fb 100755
--- a/bigbluebutton-html5/private/config/settings.yml
+++ b/bigbluebutton-html5/private/config/settings.yml
@@ -505,6 +505,9 @@ public:
enabled: true
id: captions
dictation: false
+ enableAutomaticTranslation: false
+ googleTranslateUrl: https://script.google.com/macros/s/YOUR_ID
+ #deeplTranslateUrl: https://api-free.deepl.com/v2/translate?auth_key=YOUR_KEY
background: '#000000'
font:
color: '#ffffff'
diff --git a/bigbluebutton-html5/public/locales/en.json b/bigbluebutton-html5/public/locales/en.json
index 72c82e2cb7f6..d932cc42c157 100755
--- a/bigbluebutton-html5/public/locales/en.json
+++ b/bigbluebutton-html5/public/locales/en.json
@@ -714,6 +714,7 @@
"app.audio.captions.button.stop": "Stop closed captions",
"app.audio.captions.button.language": "Language",
"app.audio.captions.button.transcription": "Transcription",
+ "app.audio.captions.button.translation": "Translation",
"app.audio.captions.button.transcriptionSettings": "Transcription settings",
"app.audio.captions.speech.title": "Automatic transcription",
"app.audio.captions.speech.disabled": "Disabled",
diff --git a/bigbluebutton-html5/public/locales/ja.json b/bigbluebutton-html5/public/locales/ja.json
index f924e2abc364..a2894c3108a0 100644
--- a/bigbluebutton-html5/public/locales/ja.json
+++ b/bigbluebutton-html5/public/locales/ja.json
@@ -714,6 +714,7 @@
"app.audio.captions.button.stop": "字幕を停止",
"app.audio.captions.button.language": "言語",
"app.audio.captions.button.transcription": "文字起こし",
+ "app.audio.captions.button.translation": "翻訳",
"app.audio.captions.button.transcriptionSettings": "文字起こし設定",
"app.audio.captions.speech.title": "自動文字起こし",
"app.audio.captions.speech.disabled": "無効",
diff --git a/record-and-playback/core/Gemfile b/record-and-playback/core/Gemfile
index b902f7d32fb8..91f471f0fb47 100644
--- a/record-and-playback/core/Gemfile
+++ b/record-and-playback/core/Gemfile
@@ -36,6 +36,7 @@ gem 'resque', '~> 2.4'
gem 'bbbevents', '~> 1.2'
gem 'rake', '>= 12.3', '<14'
gem 'tzinfo', '>= 1.2.10'
+gem 'ffi-icu'
group :test, optional: true do
gem 'rubocop', '~> 1.31.1'
diff --git a/record-and-playback/core/Gemfile.lock b/record-and-playback/core/Gemfile.lock
index e16775b1e0f1..a6c277aeceaf 100644
--- a/record-and-playback/core/Gemfile.lock
+++ b/record-and-playback/core/Gemfile.lock
@@ -15,6 +15,8 @@ GEM
crass (1.0.6)
fastimage (2.2.6)
ffi (1.15.5)
+ ffi-icu (0.5.0)
+ ffi (~> 1.0, >= 1.0.9)
i18n (1.12.0)
concurrent-ruby (~> 1.0)
java_properties (0.0.4)
@@ -93,6 +95,7 @@ DEPENDENCIES
bbbevents (~> 1.2)
builder (~> 3.2)
fastimage (~> 2.1)
+ ffi-icu
java_properties
journald-logger (~> 3.0)
jwt (~> 2.2)