diff --git a/bigbluebutton-html5/imports/api/captions/server/methods.js b/bigbluebutton-html5/imports/api/captions/server/methods.js index 2444e7baddd1e26f59937fc79206eb4494abb601..771580d706254482a35143bee9b3eff76b6e6d23 100644 --- a/bigbluebutton-html5/imports/api/captions/server/methods.js +++ b/bigbluebutton-html5/imports/api/captions/server/methods.js @@ -1,6 +1,8 @@ import { Meteor } from 'meteor/meteor'; import takeOwnership from '/imports/api/captions/server/methods/takeOwnership'; +import appendText from '/imports/api/captions/server/methods/appendText'; Meteor.methods({ takeOwnership, + appendText, }); diff --git a/bigbluebutton-html5/imports/api/captions/server/methods/appendText.js b/bigbluebutton-html5/imports/api/captions/server/methods/appendText.js new file mode 100644 index 0000000000000000000000000000000000000000..1afe735c0e6a5546eb30150efc36dcb44ed585b9 --- /dev/null +++ b/bigbluebutton-html5/imports/api/captions/server/methods/appendText.js @@ -0,0 +1,30 @@ +import axios from 'axios'; +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; +import { + generatePadId, +} from '/imports/api/captions/server/helpers'; +import { + appendTextURL, +} from '/imports/api/note/server/helpers'; + +export default function appendText(body, text, locale) { + const { meetingId } = body; + + check(meetingId, String); + check(text, String); + check(locale, String); + + const padId = generatePadId(meetingId, locale); + + axios({ + method: 'get', + url: appendTextURL(padId, text), + responseType: 'json', + }).then((response) => { + const { status } = response; + if (status === 200) { + Logger.verbose(`Appended text for padId:${padId}`); + } + }).catch(error => Logger.error(`Could not append captions for padId=${padId}: ${error}`)); +} diff --git a/bigbluebutton-html5/imports/api/note/server/helpers.js b/bigbluebutton-html5/imports/api/note/server/helpers.js index 04a25ae1277034d622378ff5c333ca87b6c81d28..e9c3e640e59b910ed2afd5e8d4081cb251aeb4f2 100644 --- a/bigbluebutton-html5/imports/api/note/server/helpers.js +++ b/bigbluebutton-html5/imports/api/note/server/helpers.js @@ -10,6 +10,8 @@ const createPadURL = padId => `${BASE_URL}/createPad?apikey=${ETHERPAD.apikey}&p const getReadOnlyIdURL = padId => `${BASE_URL}/getReadOnlyID?apikey=${ETHERPAD.apikey}&padID=${padId}`; +const appendTextURL = (padId, text) => `${BASE_URL}/appendText?apikey=${ETHERPAD.apikey}&padID=${padId}&text=${encodeURIComponent(text)}`; + const generateNoteId = (meetingId) => { const noteId = hashFNV32a(meetingId, true); return noteId; @@ -48,5 +50,6 @@ export { getReadOnlyIdURL, isEnabled, getDataFromResponse, + appendTextURL, processForNotePadOnly, }; diff --git a/bigbluebutton-html5/imports/ui/components/captions/pad/component.jsx b/bigbluebutton-html5/imports/ui/components/captions/pad/component.jsx index a8fb89c2302fec2c1a50d92a9ba658889b1a33db..23fcd6b55ad98879c014a6497091ea9f0cb2bc2c 100644 --- a/bigbluebutton-html5/imports/ui/components/captions/pad/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/captions/pad/component.jsx @@ -1,9 +1,10 @@ -import React from 'react'; +import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { Session } from 'meteor/session'; import { defineMessages, injectIntl } from 'react-intl'; import injectWbResizeEvent from '/imports/ui/components/presentation/resize-wrapper/component'; import Button from '/imports/ui/components/button/component'; +import logger from '/imports/startup/client/logger'; import PadService from './service'; import CaptionsService from '/imports/ui/components/captions/service'; import { styles } from './styles'; @@ -21,11 +22,32 @@ const intlMessages = defineMessages({ id: 'app.captions.pad.ownership', description: 'Label for taking ownership of closed captions pad', }, + interimResult: { + id: 'app.captions.pad.interimResult', + description: 'Title for speech recognition interim results', + }, + dictationStart: { + id: 'app.captions.pad.dictationStart', + description: 'Label for starting speech recognition', + }, + dictationStop: { + id: 'app.captions.pad.dictationStop', + description: 'Label for stoping speech recognition', + }, + dictationOnDesc: { + id: 'app.captions.pad.dictationOnDesc', + description: 'Aria description for button that turns on speech recognition', + }, + dictationOffDesc: { + id: 'app.captions.pad.dictationOffDesc', + description: 'Aria description for button that turns off speech recognition', + }, }); const propTypes = { locale: PropTypes.string.isRequired, ownerId: PropTypes.string.isRequired, + currentUserId: PropTypes.string.isRequired, padId: PropTypes.string.isRequired, readOnlyPadId: PropTypes.string.isRequired, name: PropTypes.string.isRequired, @@ -35,63 +57,204 @@ const propTypes = { }).isRequired, }; -const Pad = (props) => { - const { - locale, - intl, - padId, - readOnlyPadId, - ownerId, - name, - amIModerator, - } = props; - - if (!amIModerator) { - Session.set('openPanel', 'userlist'); +class Pad extends PureComponent { + static getDerivedStateFromProps(nextProps) { + if (nextProps.ownerId !== nextProps.currentUserId) { + return ({ listening: false }); + } return null; } - const url = PadService.getPadURL(padId, readOnlyPadId, ownerId); - - return ( - <div className={styles.pad}> - <header className={styles.header}> - <div className={styles.title}> - <Button - onClick={() => { Session.set('openPanel', 'userlist'); }} - aria-label={intl.formatMessage(intlMessages.hide)} - label={name} - icon="left_arrow" - className={styles.hideBtn} - /> - </div> - {CaptionsService.canIOwnThisPad(ownerId) - ? ( + constructor(props) { + super(props); + + this.state = { + listening: false, + }; + + const { locale } = props; + this.recognition = CaptionsService.initSpeechRecognition(locale); + + this.toggleListen = this.toggleListen.bind(this); + this.handleListen = this.handleListen.bind(this); + } + + componentDidUpdate() { + const { + locale, + ownerId, + currentUserId, + } = this.props; + + if (this.recognition) { + this.recognition.lang = locale; + if (ownerId !== currentUserId) this.recognition.stop(); + } + } + + toggleListen() { + const { + listening, + } = this.state; + + this.setState({ + listening: !listening, + }, this.handleListen); + } + + handleListen() { + const { + locale, + } = this.props; + + const { + listening, + } = this.state; + + if (this.recognition) { + // Starts and stops the recognition when listening. + // Throws an error if start() is called on a recognition that has already been started. + if (listening) { + this.recognition.start(); + } else { + this.recognition.stop(); + } + + // Stores the voice recognition results that have been verified. + let finalTranscript = ''; + + this.recognition.onresult = (event) => { + const { + resultIndex, + results, + } = event; + + // Stores the first guess at what was recognised (Not always accurate). + let interimTranscript = ''; + + // Loops through the results to check if any of the entries have been validated, + // signaled by the isFinal flag. + for (let i = resultIndex; i < results.length; i += 1) { + const { transcript } = event.results[i][0]; + if (results[i].isFinal) finalTranscript += `${transcript} `; + else interimTranscript += transcript; + } + + // Adds the interimTranscript text to the iterimResultContainer to show + // what's being said while speaking. + if (this.iterimResultContainer) { + this.iterimResultContainer.innerHTML = interimTranscript; + } + + const newEntry = finalTranscript !== ''; + + // Changes to the finalTranscript are shown to in the captions + if (newEntry) { + const text = finalTranscript.trimRight(); + CaptionsService.appendText(text, locale); + finalTranscript = ''; + } + }; + + this.recognition.onerror = (event) => { + logger.error({ logCode: 'captions_recognition' }, event.error); + }; + } + } + + render() { + const { + locale, + intl, + padId, + readOnlyPadId, + ownerId, + name, + amIModerator, + } = this.props; + + if (!amIModerator) { + Session.set('openPanel', 'userlist'); + return null; + } + + const { listening } = this.state; + const url = PadService.getPadURL(padId, readOnlyPadId, ownerId); + + return ( + <div className={styles.pad}> + <header className={styles.header}> + <div className={styles.title}> <Button - icon="pen_tool" - size="sm" - ghost - color="dark" - hideLabel - onClick={() => { CaptionsService.takeOwnership(locale); }} - aria-label={intl.formatMessage(intlMessages.takeOwnership)} - label={intl.formatMessage(intlMessages.takeOwnership)} + onClick={() => { Session.set('openPanel', 'userlist'); }} + aria-label={intl.formatMessage(intlMessages.hide)} + label={name} + icon="left_arrow" + className={styles.hideBtn} /> - ) : null + </div> + {CaptionsService.canIDictateThisPad(ownerId) + ? ( + <span> + <Button + onClick={() => { this.toggleListen(); }} + label={listening + ? intl.formatMessage(intlMessages.dictationStop) + : intl.formatMessage(intlMessages.dictationStart) + } + aria-describedby="dictationBtnDesc" + color="primary" + disabled={!this.recognition} + /> + <div id="dictationBtnDesc" hidden> + {listening + ? intl.formatMessage(intlMessages.dictationOffDesc) + : intl.formatMessage(intlMessages.dictationOnDesc) + } + </div> + </span> + ) : null + } + {CaptionsService.canIOwnThisPad(ownerId) + ? ( + <Button + icon="pen_tool" + size="sm" + ghost + color="dark" + hideLabel + onClick={() => { CaptionsService.takeOwnership(locale); }} + aria-label={intl.formatMessage(intlMessages.takeOwnership)} + label={intl.formatMessage(intlMessages.takeOwnership)} + /> + ) : null } - </header> - <iframe - title="etherpad" - src={url} - aria-describedby="padEscapeHint" - /> - <span id="padEscapeHint" className={styles.hint} aria-hidden> - {intl.formatMessage(intlMessages.tip)} - </span> - </div> - ); -}; - -Pad.propTypes = propTypes; + </header> + {listening ? ( + <div> + <span className={styles.interimTitle}> + {intl.formatMessage(intlMessages.interimResult)} + </span> + <div + className={styles.processing} + ref={(node) => { this.iterimResultContainer = node; }} + /> + </div> + ) : null + } + <iframe + title="etherpad" + src={url} + aria-describedby="padEscapeHint" + /> + <span id="padEscapeHint" className={styles.hint} aria-hidden> + {intl.formatMessage(intlMessages.tip)} + </span> + </div> + ); + } +} export default injectWbResizeEvent(injectIntl(Pad)); + +Pad.propTypes = propTypes; diff --git a/bigbluebutton-html5/imports/ui/components/captions/pad/container.jsx b/bigbluebutton-html5/imports/ui/components/captions/pad/container.jsx index 98c22f4239d04b48db361b571e684aee306fba58..a8fcb24fc1de29f11001235eefa8741c1f27c5af 100644 --- a/bigbluebutton-html5/imports/ui/components/captions/pad/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/captions/pad/container.jsx @@ -1,8 +1,9 @@ import React, { PureComponent } from 'react'; import { withTracker } from 'meteor/react-meteor-data'; import { Session } from 'meteor/session'; -import Pad from './component'; import CaptionsService from '/imports/ui/components/captions/service'; +import PadService from './service'; +import Pad from './component'; class PadContainer extends PureComponent { render() { @@ -25,12 +26,14 @@ export default withTracker(() => { } = caption; const { name } = caption ? caption.locale : ''; + return { locale, name, ownerId, padId, readOnlyPadId, + currentUserId: PadService.getCurrentUser().userId, amIModerator: CaptionsService.amIModerator(), }; })(PadContainer); diff --git a/bigbluebutton-html5/imports/ui/components/captions/pad/service.js b/bigbluebutton-html5/imports/ui/components/captions/pad/service.js index 237adc6f1765a040d07fa234f74d1775da896f13..a993748b313173f6dabebd8a47d54c0178258344 100644 --- a/bigbluebutton-html5/imports/ui/components/captions/pad/service.js +++ b/bigbluebutton-html5/imports/ui/components/captions/pad/service.js @@ -43,4 +43,5 @@ const getPadURL = (padId, readOnlyPadId, ownerId) => { export default { getPadURL, + getCurrentUser, }; diff --git a/bigbluebutton-html5/imports/ui/components/captions/pad/styles.scss b/bigbluebutton-html5/imports/ui/components/captions/pad/styles.scss index b9ff521619e196881be546b338f65e0b224b50d7..9bc5b5b84ffed9cf6fa7597f84bbc2d19efd3065 100644 --- a/bigbluebutton-html5/imports/ui/components/captions/pad/styles.scss +++ b/bigbluebutton-html5/imports/ui/components/captions/pad/styles.scss @@ -1,6 +1,22 @@ @import "/imports/ui/stylesheets/mixins/focus"; @import "/imports/ui/stylesheets/variables/_all"; +:root { + --speech-results-width: 22.3rem; +} + +.processing { + margin-top: var(--lg-padding-y); + margin-bottom: var(--lg-padding-y); + min-height: var(--jumbo-padding-x); + width: var(--speech-results-width); + border: solid var(--border-size-small) var(--color-gray); +} + +.interimTitle { + margin-top: var(--lg-padding-y); +} + .pad { background-color: var(--color-white); padding: var(--md-padding-x); diff --git a/bigbluebutton-html5/imports/ui/components/captions/service.js b/bigbluebutton-html5/imports/ui/components/captions/service.js index af830a816e457cadb791e989aebe88d05e0add59..17b31c7f51f560e2655d16ccdd875ff9be00a5b0 100644 --- a/bigbluebutton-html5/imports/ui/components/captions/service.js +++ b/bigbluebutton-html5/imports/ui/components/captions/service.js @@ -68,6 +68,16 @@ const takeOwnership = (locale) => { makeCall('takeOwnership', locale); }; +const formatEntry = (entry) => { + const letterIndex = entry.charAt(0) === ' ' ? 1 : 0; + const formattedEntry = `${entry.charAt(letterIndex).toUpperCase() + entry.slice(letterIndex + 1)}.\n\n`; + return formattedEntry; +}; + +const appendText = (text, locale) => { + makeCall('appendText', formatEntry(text), locale); +}; + const canIOwnThisPad = (ownerId) => { const { userID } = Auth; if (!CAPTIONS_CONFIG.takeOwnership) return false; @@ -75,6 +85,15 @@ const canIOwnThisPad = (ownerId) => { return ownerId !== userID; }; +const canIDictateThisPad = (ownerId) => { + const { userID } = Auth; + if (!CAPTIONS_CONFIG.enableDictation) return false; + if (ownerId === '') return false; + const SpeechRecognitionAPI = getSpeechRecognitionAPI(); + if (!SpeechRecognitionAPI) return false; + return ownerId === userID; +}; + const setActiveCaptions = (locale) => { Session.set('activeCaptions', locale); }; @@ -137,13 +156,32 @@ const amIModerator = () => { return mapUser(currentUser).isModerator; }; +const getSpeechRecognitionAPI = () => { + return window.SpeechRecognition || window.webkitSpeechRecognition; +}; + +const initSpeechRecognition = (locale) => { + const SpeechRecognitionAPI = getSpeechRecognitionAPI(); + let recognition = null; + if (SpeechRecognitionAPI) { + recognition = new SpeechRecognitionAPI(); + recognition.continuous = true; + recognition.interimResults = true; + recognition.lang = locale; + } + + return recognition; +}; + export default { getCaptionsData, getAvailableLocales, getOwnedLocales, takeOwnership, + appendText, getCaptions, canIOwnThisPad, + canIDictateThisPad, getCaptionsSettings, isCaptionsEnabled, isCaptionsAvailable, @@ -152,4 +190,5 @@ export default { activateCaptions, formatCaptionsText, amIModerator, + initSpeechRecognition, }; diff --git a/bigbluebutton-html5/private/config/settings.yml b/bigbluebutton-html5/private/config/settings.yml index 6e8e747126a111f19caad55b572ee38c2a9e5f43..9644413dc35a8ef26965ac9d7f811eaad2d0d92b 100755 --- a/bigbluebutton-html5/private/config/settings.yml +++ b/bigbluebutton-html5/private/config/settings.yml @@ -144,6 +144,7 @@ public: max_custom: 5 captions: enabled: false + enableDictation: false backgroundColor: "#000000" fontColor: "#FFFFFF" fontFamily: Calibri diff --git a/bigbluebutton-html5/private/locales/en.json b/bigbluebutton-html5/private/locales/en.json index d917c52cef3a6618c7a9967f39540870470fd6b6..0fb10450820472af6cbf778240c817895d76c5b0 100755 --- a/bigbluebutton-html5/private/locales/en.json +++ b/bigbluebutton-html5/private/locales/en.json @@ -37,6 +37,11 @@ "app.captions.pad.hide": "Hide closed captions", "app.captions.pad.tip": "Press Esc to focus editor toolbar", "app.captions.pad.ownership": "Take ownership", + "app.captions.pad.interimResult": "Interim results", + "app.captions.pad.dictationStart": "Start Dictation", + "app.captions.pad.dictationStop": "Stop Dictation", + "app.captions.pad.dictationOnDesc": "Turns speech recognition on", + "app.captions.pad.dictationOffDesc": "Turns speech recognition off", "app.note.title": "Shared Notes", "app.note.label": "Note", "app.note.hideNoteLabel": "Hide note",