You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. import { AnyAction } from 'redux';
  2. import { IStore } from '../app/types';
  3. import { ENDPOINT_MESSAGE_RECEIVED } from '../base/conference/actionTypes';
  4. import { isJwtFeatureEnabled } from '../base/jwt/functions';
  5. import JitsiMeetJS from '../base/lib-jitsi-meet';
  6. import { isLocalParticipantModerator } from '../base/participants/functions';
  7. import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
  8. import {
  9. SET_REQUESTING_SUBTITLES,
  10. TOGGLE_REQUESTING_SUBTITLES
  11. } from './actionTypes';
  12. import {
  13. removeTranscriptMessage,
  14. setRequestingSubtitles,
  15. updateTranscriptMessage
  16. } from './actions.any';
  17. import { notifyTranscriptionChunkReceived } from './functions';
  18. import logger from './logger';
  19. import { ITranscriptMessage } from './types';
  20. /**
  21. * The type of json-message which indicates that json carries a
  22. * transcription result.
  23. */
  24. const JSON_TYPE_TRANSCRIPTION_RESULT = 'transcription-result';
  25. /**
  26. * The type of json-message which indicates that json carries a
  27. * translation result.
  28. */
  29. const JSON_TYPE_TRANSLATION_RESULT = 'translation-result';
  30. /**
  31. * The local participant property which is used to set whether the local
  32. * participant wants to have a transcriber in the room.
  33. */
  34. const P_NAME_REQUESTING_TRANSCRIPTION = 'requestingTranscription';
  35. /**
  36. * The local participant property which is used to store the language
  37. * preference for translation for a participant.
  38. */
  39. const P_NAME_TRANSLATION_LANGUAGE = 'translation_language';
  40. /**
  41. * The dial command to use for starting a transcriber.
  42. */
  43. const TRANSCRIBER_DIAL_NUMBER = 'jitsi_meet_transcribe';
  44. /**
  45. * Time after which the rendered subtitles will be removed.
  46. */
  47. const REMOVE_AFTER_MS = 3000;
  48. /**
  49. * Stability factor for a transcription. We'll treat a transcript as stable
  50. * beyond this value.
  51. */
  52. const STABLE_TRANSCRIPTION_FACTOR = 0.85;
  53. /**
  54. * Middleware that catches actions related to transcript messages to be rendered
  55. * in {@link Captions}.
  56. *
  57. * @param {Store} store - The redux store.
  58. * @returns {Function}
  59. */
  60. MiddlewareRegistry.register(store => next => action => {
  61. switch (action.type) {
  62. case ENDPOINT_MESSAGE_RECEIVED:
  63. return _endpointMessageReceived(store, next, action);
  64. case TOGGLE_REQUESTING_SUBTITLES: {
  65. const state = store.getState()['features/subtitles'];
  66. const toggledValue = !state._requestingSubtitles;
  67. _requestingSubtitlesChange(store, toggledValue, state._language);
  68. break;
  69. }
  70. case SET_REQUESTING_SUBTITLES:
  71. _requestingSubtitlesChange(store, action.enabled, action.language);
  72. break;
  73. }
  74. return next(action);
  75. });
  76. /**
  77. * Notifies the feature transcription that the action
  78. * {@code ENDPOINT_MESSAGE_RECEIVED} is being dispatched within a specific redux
  79. * store.
  80. *
  81. * @param {Store} store - The redux store in which the specified {@code action}
  82. * is being dispatched.
  83. * @param {Dispatch} next - The redux {@code dispatch} function to
  84. * dispatch the specified {@code action} to the specified {@code store}.
  85. * @param {Action} action - The redux action {@code ENDPOINT_MESSAGE_RECEIVED}
  86. * which is being dispatched in the specified {@code store}.
  87. * @private
  88. * @returns {Object} The value returned by {@code next(action)}.
  89. */
  90. function _endpointMessageReceived(store: IStore, next: Function, action: AnyAction) {
  91. const { data: json } = action;
  92. if (![ JSON_TYPE_TRANSCRIPTION_RESULT, JSON_TYPE_TRANSLATION_RESULT ].includes(json?.type)) {
  93. return next(action);
  94. }
  95. const { dispatch, getState } = store;
  96. const state = getState();
  97. const language
  98. = state['features/base/conference'].conference
  99. ?.getLocalParticipantProperty(P_NAME_TRANSLATION_LANGUAGE);
  100. const { dumpTranscript, skipInterimTranscriptions } = state['features/base/config'].testing ?? {};
  101. const transcriptMessageID = json.message_id;
  102. const { name, id, avatar_url: avatarUrl } = json.participant;
  103. const participant = {
  104. avatarUrl,
  105. id,
  106. name
  107. };
  108. if (json.type === JSON_TYPE_TRANSLATION_RESULT && json.language === language) {
  109. // Displays final results in the target language if translation is
  110. // enabled.
  111. const newTranscriptMessage = {
  112. clearTimeOut: undefined,
  113. final: json.text,
  114. participant
  115. };
  116. _setClearerOnTranscriptMessage(dispatch, transcriptMessageID, newTranscriptMessage);
  117. dispatch(updateTranscriptMessage(transcriptMessageID, newTranscriptMessage));
  118. } else if (json.type === JSON_TYPE_TRANSCRIPTION_RESULT) {
  119. // Displays interim and final results without any translation if
  120. // translations are disabled.
  121. const { text } = json.transcript[0];
  122. // First, notify the external API.
  123. if (!(json.is_interim && skipInterimTranscriptions)) {
  124. const txt: any = {};
  125. if (!json.is_interim) {
  126. txt.final = text;
  127. } else if (json.stability > STABLE_TRANSCRIPTION_FACTOR) {
  128. txt.stable = text;
  129. } else {
  130. txt.unstable = text;
  131. }
  132. notifyTranscriptionChunkReceived(
  133. transcriptMessageID,
  134. json.language,
  135. participant,
  136. txt,
  137. store
  138. );
  139. if (navigator.product !== 'ReactNative') {
  140. // Dump transcript in a <transcript> element for debugging purposes.
  141. if (!json.is_interim && dumpTranscript) {
  142. try {
  143. let elem = document.body.getElementsByTagName('transcript')[0];
  144. // eslint-disable-next-line max-depth
  145. if (!elem) {
  146. elem = document.createElement('transcript');
  147. document.body.appendChild(elem);
  148. }
  149. elem.append(`${new Date(json.timestamp).toISOString()} ${participant.name}: ${text}`);
  150. } catch (_) {
  151. // Ignored.
  152. }
  153. }
  154. }
  155. }
  156. // If the user is not requesting transcriptions just bail.
  157. // Regex to filter out all possible country codes after language code:
  158. // this should catch all notations like 'en-GB' 'en_GB' and 'enGB'
  159. // and be independent of the country code length
  160. if (json.language.replace(/[-_A-Z].*/, '') !== language) {
  161. return next(action);
  162. }
  163. if (json.is_interim && skipInterimTranscriptions) {
  164. return next(action);
  165. }
  166. // We update the previous transcript message with the same
  167. // message ID or adds a new transcript message if it does not
  168. // exist in the map.
  169. const existingMessage = state['features/subtitles']._transcriptMessages.get(transcriptMessageID);
  170. const newTranscriptMessage: ITranscriptMessage = {
  171. clearTimeOut: existingMessage?.clearTimeOut,
  172. participant
  173. };
  174. _setClearerOnTranscriptMessage(dispatch, transcriptMessageID, newTranscriptMessage);
  175. // If this is final result, update the state as a final result
  176. // and start a count down to remove the subtitle from the state
  177. if (!json.is_interim) {
  178. newTranscriptMessage.final = text;
  179. } else if (json.stability > STABLE_TRANSCRIPTION_FACTOR) {
  180. // If the message has a high stability, we can update the
  181. // stable field of the state and remove the previously
  182. // unstable results
  183. newTranscriptMessage.stable = text;
  184. } else {
  185. // Otherwise, this result has an unstable result, which we
  186. // add to the state. The unstable result will be appended
  187. // after the stable part.
  188. newTranscriptMessage.unstable = text;
  189. }
  190. dispatch(updateTranscriptMessage(transcriptMessageID, newTranscriptMessage));
  191. }
  192. return next(action);
  193. }
  194. /**
  195. * Toggle the local property 'requestingTranscription'. This will cause Jicofo
  196. * and Jigasi to decide whether the transcriber needs to be in the room.
  197. *
  198. * @param {Store} store - The redux store.
  199. * @param {boolean} enabled - Whether subtitles should be enabled or not.
  200. * @param {string} language - The language to use for translation.
  201. * @private
  202. * @returns {void}
  203. */
  204. function _requestingSubtitlesChange(
  205. { dispatch, getState }: IStore,
  206. enabled: boolean,
  207. language?: string | null) {
  208. const state = getState();
  209. const { conference } = state['features/base/conference'];
  210. conference?.setLocalParticipantProperty(
  211. P_NAME_REQUESTING_TRANSCRIPTION,
  212. enabled);
  213. if (enabled && conference?.getTranscriptionStatus() === JitsiMeetJS.constants.transcriptionStatus.OFF) {
  214. const isModerator = isLocalParticipantModerator(state);
  215. const featureAllowed = isJwtFeatureEnabled(getState(), 'transcription', isModerator, isModerator);
  216. if (featureAllowed) {
  217. conference?.dial(TRANSCRIBER_DIAL_NUMBER)
  218. .catch((e: any) => {
  219. logger.error('Error dialing', e);
  220. // let's back to the correct state
  221. dispatch(setRequestingSubtitles(false, false, null));
  222. });
  223. }
  224. }
  225. if (enabled && language) {
  226. conference?.setLocalParticipantProperty(
  227. P_NAME_TRANSLATION_LANGUAGE,
  228. language.replace('translation-languages:', ''));
  229. }
  230. }
  231. /**
  232. * Set a timeout on a TranscriptMessage object so it clears itself when it's not
  233. * updated.
  234. *
  235. * @param {Function} dispatch - Dispatch remove action to store.
  236. * @param {string} transcriptMessageID - The id of the message to remove.
  237. * @param {Object} transcriptMessage - The message to remove.
  238. * @returns {void}
  239. */
  240. function _setClearerOnTranscriptMessage(
  241. dispatch: IStore['dispatch'],
  242. transcriptMessageID: string,
  243. transcriptMessage: { clearTimeOut?: number; }) {
  244. if (transcriptMessage.clearTimeOut) {
  245. clearTimeout(transcriptMessage.clearTimeOut);
  246. }
  247. transcriptMessage.clearTimeOut
  248. = window.setTimeout(
  249. () => dispatch(removeTranscriptMessage(transcriptMessageID)),
  250. REMOVE_AFTER_MS);
  251. }