Skip to content

Commit

Permalink
Merge pull request #403 from code-dot-org/initial-text-to-speech
Browse files Browse the repository at this point in the history
Initial text to speech support
  • Loading branch information
breville authored Jul 15, 2024
2 parents d7cd203 + dc10149 commit 7af87ee
Show file tree
Hide file tree
Showing 9 changed files with 3,297 additions and 457 deletions.
108 changes: 97 additions & 11 deletions src/oceans/components/common/Guide.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,95 @@ import colors from '@ml/oceans/styles/colors';
import I18n from '@ml/oceans/i18n';
import {Button} from '@ml/oceans/components/common';
import arrowDownImage from '@public/images/arrow-down.png';
import {
startTextToSpeech,
stopTextToSpeech,
hasTextToSpeechVoices
} from '@ml/utils/TextToSpeech';
import fingerClickIcon1 from '@public/images/finger-click-icon-1.svg';
import fingerClickIcon2 from '@public/images/finger-click-icon-2.svg';

export const stopTypingSounds = () => {
const state = getState();
if (state.guideTypingTimer) {
clearInterval(state.guideTypingTimer);
setState({guideTypingTimer: undefined}, {skipCallback: true});
}
};

let UnwrappedGuide = class Guide extends React.Component {
onShowing() {
onTypingDone() {
clearInterval(getState().guideTypingTimer);
setState({guideShowing: true, guideTypingTimer: null});
setState({guideShowing: true, guideTypingTimer: undefined});
}

dismissGuideClick() {
const dismissed = guide.dismissCurrentGuide();
if (dismissed) {
soundLibrary.playSound('other');
onGuideClick = () => {
const state = getState();
const currentGuide = guide.getCurrentGuide();

if (this.attemptTextToSpeechTextToSpeech(true)) {
// This click started text to speech.
setState(
{
hasTextToSpeechStartedByClick: true,
textToSpeechCurrentGuide: currentGuide
},
{skipCallback: true}
);
} else {
// This click did not start text to speech, so attempt
// to dismiss the guide.
const dismissed = guide.dismissCurrentGuide();
if (dismissed) {
if (state.textToSpeechLocale) {
stopTextToSpeech();
}
soundLibrary.playSound('other');
}
}
}
};

// Called from both the guide click handler and the render method, and
// attempts to play text to speech if needed. Returns true if it believes
// it started text to speech.
attemptTextToSpeechTextToSpeech = inClickHandler => {
const state = getState();
const currentGuide = guide.getCurrentGuide();

// Do nothing if text to speech is not desired or yet available.
if (!state.textToSpeechLocale || !hasTextToSpeechVoices()) {
return false;
}

// Do nothing if there is no current guide, or if we've already started
// text to speech for the current guide (which might have finished
// playing by now).
if (
!currentGuide ||
state.textToSpeechCurrentGuide === currentGuide
) {
return false;
}

// In this implementation, we want to start the first play of text to
// speech from a click handler, but all subsequent plays when we first
// render a new piece of text, rather than from a click handler.
// Therefore:
// If we are in a click handler, do nothing if we've already started
// text to speech from a click handler.
// If we are not in a click handler, do nothing if we've never started
// from a click handler before.
if (inClickHandler === state.hasTextToSpeechStartedByClick) {
return false;
}

// Make an attempt to play text to speech, and return whether we
// believe it has started.
return startTextToSpeech(
currentGuide.textFn(getState()),
state.textToSpeechLocale
);
};

render() {
const state = getState();
Expand All @@ -45,11 +119,21 @@ let UnwrappedGuide = class Guide extends React.Component {
}

// Start playing the typing sounds.
if (!state.guideShowing && !state.guideTypingTimer && currentGuide) {
if (
!state.textToSpeechLocale &&
!state.guideShowing &&
!state.guideTypingTimer &&
currentGuide
) {
const guideTypingTimer = setInterval(() => {
soundLibrary.playSound('no', 0.5);
}, 1000 / 10);
setState({guideTypingTimer});
setState({guideTypingTimer}, {skipCallback: true});
}

if (this.attemptTextToSpeechTextToSpeech(false)) {
// This call started text to speech.
setState({textToSpeechCurrentGuide: currentGuide}, {skipCallback: true});
}

const renderClickToContinueReminder =
Expand All @@ -72,7 +156,7 @@ let UnwrappedGuide = class Guide extends React.Component {
<div
key={currentGuide.id}
style={guideBgStyle}
onClick={this.dismissGuideClick}
onClick={this.onGuideClick}
id="uitest-dismiss-guide"
>
<div
Expand All @@ -87,16 +171,18 @@ let UnwrappedGuide = class Guide extends React.Component {
{I18n.t('didYouKnow')}
</div>
)}

<div style={styles.guideTypingText}>
<Typist
avgTypingDelay={35}
stdTypingDelay={15}
cursor={{show: false}}
onTypingDone={this.onShowing}
onTypingDone={this.onTypingDone}
>
{currentGuide.textFn(getState())}
</Typist>
</div>

<div
style={
currentGuide.style === 'Info'
Expand Down
12 changes: 5 additions & 7 deletions src/oceans/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,16 @@ import './assetPath';
import {queryStrFor} from './helpers';
import {initAll} from './init';
import Sounds from './Sounds';
import {getState} from './state';
import {stopTypingSounds} from '@ml/oceans/components/common/Guide';

let currentAppMode = queryStrFor('mode') || 'fishvtrash';
const currentGuides = queryStrFor('guides');
let textToSpeechLocale = queryStrFor('tts');
let canvas, backgroundCanvas;

function onLevelChange(event) {
// Stop any typing sounds. (Don't modify state, though, since that would
// callback into our renderer again.)
const existingTypingTimer = getState().guideTypingTimer;
if (existingTypingTimer) {
clearInterval(existingTypingTimer);
}
// Stop any typing sounds.
stopTypingSounds();

currentAppMode = event.target.id;
initDemoPage();
Expand All @@ -28,6 +25,7 @@ function initDemoPage() {
initAll({
appMode: currentAppMode,
guides: currentGuides,
textToSpeechLocale,
onContinue,
canvas,
backgroundCanvas,
Expand Down
Loading

0 comments on commit 7af87ee

Please sign in to comment.