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.

middleware.ts 11KB

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