Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

TTS Autoplay bug fix #56324

Merged
merged 29 commits into from
Apr 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
01264e9
Moves VOICES to shared constants from the ruby side.
wilkie Oct 6, 2023
699c872
Uses shared Voices constant in front-end and cleans it up in ruby land.
wilkie Oct 7, 2023
4047f7c
Merge staging
juanmanzojr Jan 4, 2024
32c2a9f
Audio queue for tts
juanmanzojr Jan 5, 2024
f868c3e
Merge branch 'staging' into tts-autoplay-bug-fix
juanmanzojr Jan 5, 2024
e1c0626
TTS autoplay bug fix
juanmanzojr Jan 8, 2024
bb70b0c
Audio queue for TTS
juanmanzojr Jan 10, 2024
c332fb0
Remove Radium from InlineAudio to prevent conflict in contexts
juanmanzojr Jan 18, 2024
f6fa25e
Include context in unit tests for InlineAudio
juanmanzojr Jan 18, 2024
f59a8ed
Unit tests for audio queue
juanmanzojr Feb 1, 2024
802c256
RTL changes to AudioQueue tests
juanmanzojr Feb 8, 2024
78ebfb8
Decouple AudioQueue for testing
juanmanzojr Feb 8, 2024
7cfa8e1
Tests for audio queue functions
juanmanzojr Feb 12, 2024
969a491
Staging Merge conflicts fix
juanmanzojr Mar 26, 2024
316d164
Fix linting errors
juanmanzojr Mar 26, 2024
35c575c
Fix linting errors
juanmanzojr Mar 26, 2024
3dacabd
Restore functions within AudioQueue
juanmanzojr Mar 27, 2024
434ec9b
WIP
juanmanzojr Mar 27, 2024
035af62
WIP
juanmanzojr Mar 27, 2024
afc240c
Account for unmounting of hints
juanmanzojr Mar 29, 2024
f130dbf
Remove event listner for unmounted audio
juanmanzojr Mar 29, 2024
30e7df6
Fix merge conflict
juanmanzojr Mar 29, 2024
22c2f40
Account for hints being closed while audio being played
juanmanzojr Apr 1, 2024
e5c5493
Remove console logs
juanmanzojr Apr 1, 2024
52c0d19
Remove test
juanmanzojr Apr 1, 2024
ef000d7
Tests for AudioQueue
juanmanzojr Apr 2, 2024
c42e1d2
Tests for AudioQueue
juanmanzojr Apr 2, 2024
fa30ed3
Add before each and after each action
juanmanzojr Apr 2, 2024
7337c06
Define types for Audio queue context functions
juanmanzojr Apr 2, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/script/generateSharedConstants.rb
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ def main
)

generate_shared_js_file(shared_content, "#{REPO_DIR}/apps/src/util/sharedConstants.js")
generate_shared_js_file(generate_constants('VOICES'), "#{REPO_DIR}/apps/src/util/sharedVoices.js")
generate_shared_js_file(generate_constants('APPLAB_BLOCKS'), "#{REPO_DIR}/apps/src/applab/sharedApplabBlocks.js")
generate_shared_js_file(generate_constants('APPLAB_GOAL_BLOCKS'), "#{REPO_DIR}/apps/src/applab/sharedApplabGoalBlocks.js")
generate_shared_js_file(generate_constants('GAMELAB_BLOCKS'), "#{REPO_DIR}/apps/src/p5lab/gamelab/sharedGamelabBlocks.js")
Expand Down
65 changes: 65 additions & 0 deletions apps/src/templates/instructions/AudioQueue.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import React, {
createContext,
useState,
useEffect,
useRef,
useCallback,
ReactNode,
} from 'react';

interface InlineAudio {
playAudio: () => void;
}

interface AudioQueueContextProps {
addToQueue: (inlineAudio: InlineAudio) => void;
playNextAudio: () => void;
clearQueue: () => void;
isPlaying: React.MutableRefObject<boolean>;
}

export const AudioQueueContext = createContext<AudioQueueContextProps>({
addToQueue: () => {},
playNextAudio: () => {},
clearQueue: () => {},
isPlaying: {current: false},
});

interface AudioQueueProps {
children: ReactNode;
}

export const AudioQueue: React.FC<AudioQueueProps> = ({children}) => {
const [audioQueue, setAudioQueue] = useState<InlineAudio[]>([]);
const isPlaying = useRef<boolean>(false);

const playNextAudio = useCallback(() => {
if (audioQueue.length > 0) {
const inlineAudio = audioQueue.shift();
isPlaying.current = true;
inlineAudio?.playAudio();
}
}, [audioQueue]);

const addToQueue = useCallback((inlineAudio: InlineAudio) => {
setAudioQueue(prevQueue => [...prevQueue, inlineAudio]);
}, []);

const clearQueue = useCallback(() => {
setAudioQueue([]);
}, []);

useEffect(() => {
if (!isPlaying.current) {
playNextAudio();
}
}, [audioQueue, playNextAudio]);

return (
<AudioQueueContext.Provider
value={{addToQueue, playNextAudio, clearQueue, isPlaying}}
>
{children}
</AudioQueueContext.Provider>
);
};
125 changes: 74 additions & 51 deletions apps/src/templates/instructions/InlineAudio.jsx
Original file line number Diff line number Diff line change
@@ -1,45 +1,17 @@
import classNames from 'classnames';
import md5 from 'md5';
import PropTypes from 'prop-types';
import Radium from 'radium'; // eslint-disable-line no-restricted-imports
import React from 'react';
import {connect} from 'react-redux';

import firehoseClient from '@cdo/apps/lib/util/firehose';
import i18n from '@cdo/locale';
import {Voices} from '@cdo/apps/util/sharedVoices';

import trackEvent from '../../util/trackEvent';

import moduleStyles from './inline-audio.module.scss';
import {AudioQueueContext} from './AudioQueue';

// TODO (elijah): have these constants shared w/dashboard
const VOICES = {
en_us: {
VOICE: 'sharon22k',
SPEED: 180,
SHAPE: 100,
},
es_es: {
VOICE: 'ines22k',
SPEED: 180,
SHAPE: 100,
},
es_mx: {
VOICE: 'rosa22k',
SPEED: 180,
SHAPE: 100,
},
it_it: {
VOICE: 'vittorio22k',
SPEED: 180,
SHAPE: 100,
},
pt_br: {
VOICE: 'marcia22k',
SPEED: 180,
SHAPE: 100,
},
};
import moduleStyles from './inline-audio.module.scss';

const TTS_URL = 'https://tts.code.org';

Expand Down Expand Up @@ -105,10 +77,31 @@ class InlineAudio extends React.Component {
? document.getElementById(autoplayTriggerElementId)
: document;

this.playAudio();
const {addToQueue} = this.context;
addToQueue(this);
}
}

componentWillUnmount() {
// remove reference to existing Audio object when a hint is unmounted before the audio finishes.
const audio = this.state.audio;
if (audio) {
audio.pause();
audio.removeAttribute('src');
audio.load();
}
this.setState({
audio: undefined,
playing: false,
error: false,
});
audio.removeEventListener('ended', this.audioEndedListener);

const {clearQueue, isPlaying} = this.context;
clearQueue();
if (this.state.playing) isPlaying.current = false;
}

UNSAFE_componentWillUpdate(nextProps) {
const audioTargetWillChange =
this.props.src !== nextProps.src ||
Expand Down Expand Up @@ -154,35 +147,33 @@ class InlineAudio extends React.Component {
playing: false,
autoplayed: this.props.ttsAutoplayEnabled,
});
if (this.props.ttsAutoplayEnabled) {
const {playNextAudio, isPlaying} = this.context;
isPlaying.current = this.state.playing;
playNextAudio();
}
});

audio.addEventListener('error', e => {
// e is an instance of a MediaError object
trackEvent('InlineAudio', 'error', e.target.error.code);
this.setState({
playing: false,
error: true,
});
});
audio.addEventListener('error', this.audioEndedListener);

this.setState({audio});
trackEvent('InlineAudio', 'getAudioElement', src);
return audio;
}

isLocaleSupported() {
return Object.prototype.hasOwnProperty.call(VOICES, this.props.locale);
return Object.prototype.hasOwnProperty.call(Voices, this.props.locale);
}

getAudioSrc() {
if (this.props.src) {
return this.props.src;
} else if (this.props.message && VOICES[this.props.locale]) {
const voice = VOICES[this.props.locale];
} else if (this.props.message && Voices[this.props.locale]) {
const voice = Voices[this.props.locale];
const voicePath = `${voice.VOICE}/${voice.SPEED}/${voice.SHAPE}`;

const message = this.props.message.replace('"???"', 'the question marks');
const hash = md5(message);
const hash = md5(message).toString();
const contentPath = `${hash}/${encodeURIComponent(message)}.mp3`;

return `${TTS_URL}/${voicePath}/${contentPath}`;
Expand Down Expand Up @@ -254,8 +245,23 @@ class InlineAudio extends React.Component {
pauseAudio() {
this.getAudioElement().pause();
this.setState({playing: false});
if (this.props.ttsAutoplayEnabled) {
const {clearQueue} = this.context;
clearQueue();
}
}

audioEndedListener = e => {
// e is an instance of a MediaError object
trackEvent('InlineAudio', 'error', e.target.error.code);
this.setState({
playing: false,
error: true,
});
const {isPlaying} = this.context;
isPlaying.current = this.state.playing;
};

render() {
const {isRoundedVolumeIcon, isLegacyStyles} = this.props;

Expand All @@ -275,11 +281,14 @@ class InlineAudio extends React.Component {
)}
style={this.props.style && this.props.style.wrapper}
onClick={this.toggleAudio}
aria-label={i18n.textToSpeech()}
type="button"
>
<div
style={[this.props.style && this.props.style.button]}
style={
this.props.style && this.props.style.button
? this.props.style.button
: {}
}
className={classNames(
moduleStyles.iconWrapper,
isRoundedVolumeIcon
Expand All @@ -294,7 +303,11 @@ class InlineAudio extends React.Component {
moduleStyles.buttonImg,
moduleStyles.buttonImgVolume
)}
style={[this.props.style && this.props.style.buttonImg]}
style={
this.props.style && this.props.style.buttonImg
? this.props.style.buttonImg
: {}
}
/>
</div>
<div
Expand All @@ -303,14 +316,22 @@ class InlineAudio extends React.Component {
moduleStyles.iconWrapper,
moduleStyles.iconWrapperPlayPause
)}
style={[this.props.style && this.props.style.button]}
style={
this.props.style && this.props.style.button
? this.props.style.button
: {}
}
>
<i
className={classNames(
this.state.playing ? 'fa fa-pause' : 'fa fa-play',
moduleStyles.buttonImg
)}
style={[this.props.style && this.props.style.buttonImg]}
style={
this.props.style && this.props.style.buttonImg
? this.props.style.buttonImg
: {}
}
/>
</div>
</button>
Expand All @@ -323,8 +344,10 @@ class InlineAudio extends React.Component {
InlineAudio.defaultProps = {
ttsAutoplayEnabled: false,
};
InlineAudio.contextType = AudioQueueContext;

export const UnconnectedInlineAudio = InlineAudio;

export const StatelessInlineAudio = Radium(InlineAudio);
export default connect(function propsFromStore(state) {
return {
assetUrl: state.pageConstants.assetUrl,
Expand All @@ -336,4 +359,4 @@ export default connect(function propsFromStore(state) {
isOnCSFPuzzle: !state.instructions.noInstructionsWhenCollapsed,
ttsAutoplayEnabled: state.instructions.ttsAutoplayEnabledForLevel,
};
})(StatelessInlineAudio);
})(InlineAudio);