From 31777a4541c9b7954fd029c296df486597d8ffb4 Mon Sep 17 00:00:00 2001 From: Jesse Greenberg Date: Tue, 3 Mar 2020 18:25:30 -0500 Subject: [PATCH] Create a webSpeaker and options dialog to support new self-voicing prototypes, see phetsims/gravity-force-lab-basics#193 --- js/ISLCQueryParameters.js | 7 +- js/view/WebSpeechDialogContent.js | 118 ++++++++++++++++++++++++++++++ js/view/webSpeaker.js | 89 ++++++++++++++++++++++ 3 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 js/view/WebSpeechDialogContent.js create mode 100644 js/view/webSpeaker.js diff --git a/js/ISLCQueryParameters.js b/js/ISLCQueryParameters.js index d0b8aa7..c162580 100644 --- a/js/ISLCQueryParameters.js +++ b/js/ISLCQueryParameters.js @@ -24,7 +24,12 @@ const ISLCQueryParameters = QueryStringMachine.getAll( { // Shows boundary positions of the two objects, as . The boundary positions for each // object will change depending on the size and position of both objects. - showDragBounds: { type: 'flag' } + showDragBounds: { type: 'flag' }, + + // Enables prototype "self voicing" feature set, which uses the Web Speech API to read content from the sim + // without the use of a screen reader. This is being tested for the first time in gravity-force-lab-basics, and so this query + // parameter will be used by the ISLC dependency repos, see https://github.com/phetsims/gravity-force-lab-basics/issues/193 + selfVoicing: { type: 'flag' } } ); inverseSquareLawCommon.register( 'ISLCQueryParameters', ISLCQueryParameters ); diff --git a/js/view/WebSpeechDialogContent.js b/js/view/WebSpeechDialogContent.js new file mode 100644 index 0000000..916732a --- /dev/null +++ b/js/view/WebSpeechDialogContent.js @@ -0,0 +1,118 @@ +// Copyright 2020, University of Colorado Boulder + +/** + * Content for an "Options" dialog, only used if the ?selfVoicing query parameter is used to explor prototypal "self voicing" + * feature set. This dialog allows control of output verbosity and settings for the speech synthesizer. + * + * @author Jesse Greenberg + */ + +import inverseSquareLawCommon from '../inverseSquareLawCommon.js'; +import Utils from '../../../../dot/js/Utils.js'; +import ComboBoxItem from '../../../../sun/js/ComboBoxItem.js'; +import ComboBox from '../../../../sun/js/ComboBox.js'; +import HSlider from '../../../../sun/js/HSlider.js'; +import VerticalAquaRadioButtonGroup from '../../../../sun/js/VerticalAquaRadioButtonGroup.js'; +import StringUtils from '../../../../phetcommon/js/util/StringUtils.js'; +import webSpeaker from '../../../inverse-square-law-common/js/view/webSpeaker.js'; +import PhetFont from '../../../../scenery-phet/js/PhetFont.js'; +import VBox from '../../../../scenery/js/nodes/VBox.js'; +import HBox from '../../../../scenery/js/nodes/HBox.js'; +import Text from '../../../../scenery/js/nodes/Text.js'; + +// constants +const TITLE_FONT = new PhetFont( { size: 16 } ); +const LABEL_FONT = new PhetFont( { size: 12 } ); +const INPUT_SPACING = 8; + +class WebSpeechDialogContent extends VBox { + constructor() { + + // controls for verbosity, the output designed by PhET + const verbosityControls = new VerticalAquaRadioButtonGroup( webSpeaker.verbosityProperty, + [ + { + node: new Text( 'Verbose', { font: LABEL_FONT } ), + value: webSpeaker.Verbosity.VERBOSE, + labelContent: 'Verbose' + }, + { + node: new Text( 'Brief', { font: LABEL_FONT } ), + value: webSpeaker.Verbosity.BRIEF, + labelContent: 'Brief' + } + ], { + spacing: 5 + } + ); + const labelledVerbosityControls = new VBox( { + children: [ + new Text( 'Verbosity', { font: TITLE_FONT } ), + verbosityControls + ], + align: 'center', + spacing: INPUT_SPACING + } ); + + // controls for speech synthesis, such as the rate, pitch, and voice + const voiceRateSlider = WebSpeechDialogContent.createLabelledSlider( webSpeaker.voiceRateProperty, 'Rate', 'New Voice Rate' ); + const voicePitchSlider = WebSpeechDialogContent.createLabelledSlider( webSpeaker.voicePitchProperty, 'Pitch', 'New Voice Pitch' ); + + const comboBoxItems = []; + + // only grab the first 12 options for the ComboBox, its all we have space for + webSpeaker.voices.splice( 0, 12 ).forEach( voice => { + comboBoxItems.push( new ComboBoxItem( new Text( voice.name, { font: LABEL_FONT } ), voice ) ); + } ); + const voiceComboBox = new ComboBox( comboBoxItems, webSpeaker.voiceProperty, phet.joist.sim.topLayer, { + listPosition: 'above' + } ); + + const voiceControls = new VBox( { + children: [ voiceRateSlider, voicePitchSlider, voiceComboBox ], + spacing: INPUT_SPACING + } ); + + const labelledVoiceControls = new VBox( { + children: [ + new Text( 'Voice', { font: TITLE_FONT } ), + voiceControls + ], + align: 'center', + spacing: INPUT_SPACING + } ); + + super( { + children: [ labelledVerbosityControls, labelledVoiceControls ], + spacing: 30 + } ); + + webSpeaker.voiceProperty.lazyLink( voice => { + webSpeaker.speak( 'New voice selected' ); + } ); + } +} + +// @private +// @static +WebSpeechDialogContent.createLabelledSlider = ( numberProperty, label, changeSuccessDescription ) => { + const changeSuccessPatternString = '{{successDescription}}, {{newValue}}'; + + const slider = new HSlider( numberProperty, numberProperty.range, { + endDrag: () => { + const utterance = StringUtils.fillIn( changeSuccessPatternString, { + successDescription: changeSuccessDescription, + newValue: Utils.toFixed( webSpeaker.voicePitchProperty.get(), 2 ) + } ); + webSpeaker.speak( utterance ); + } + } ); + return new HBox( { + children: [ new Text( label, { font: LABEL_FONT } ) , slider ], + spacing: INPUT_SPACING + } ); +}; + +inverseSquareLawCommon.register( 'WebSpeechDialogContent', WebSpeechDialogContent ); + +export default WebSpeechDialogContent; diff --git a/js/view/webSpeaker.js b/js/view/webSpeaker.js new file mode 100644 index 0000000..a64cb08 --- /dev/null +++ b/js/view/webSpeaker.js @@ -0,0 +1,89 @@ +// Copyright 2020, University of Colorado Boulder + +/** + * @author Jesse Greenberg + */ + +import inverseSquareLawCommon from '../inverseSquareLawCommon.js'; +import Enumeration from '../../../phet-core/js/Enumeration.js'; +import EnumerationProperty from '../../../../axon/js/EnumerationProperty.js'; +import NumberProperty from '../../../../axon/js/NumberProperty.js'; +import Property from '../../../../axon/js/Property.js'; +import Range from '../../../../dot/js/Range.js'; + +const Verbosity = Enumeration.byKeys( [ 'BRIEF', 'VERBOSE' ] ); + +class WebSpeaker { + constructor() { + + // {Property.} - self voicing content can be brief or more "verbose", debending on user selection + this.verbosityProperty = new EnumerationProperty( Verbosity, Verbosity.BRIEF ); + + // @public {null|SpeechSynthesisVoice} + this.voiceProperty = new Property( null ); + + // @public {NumberProperty} - controls the speaking rate of Web Speech + this.voiceRateProperty = new NumberProperty( 1, { range: new Range( 1, 2 ) } ); + + // {NumberProperty} - controls the + this.voicePitchProperty = new NumberProperty( 1, { range: new Range( 1, 2 ) } ); + + // create the synthesizer + this.synth = window.speechSynthesis; + + // @public {SpeechSynthesisVoice[]} - possible voices for Web Speech synthesis + this.voices = []; + + // @public {boolean} - is the WebSpeaker initialized for use? This is prototypal so it isn't always initialized + this.initialized = false; + + // On chrome, synth.getVoices() returns an empty array until the onvoiceschanged event, so we have to + // wait to populate + const populateVoicesListener = () => { + this.populateVoices(); + + // remove the listener after they have been populated once from this event + this.synth.onvoiceschanged = null; + }; + this.synth.onvoiceschanged = populateVoicesListener; + } + + /** + * Indicate that the webSpeaker is ready for use, and attempt to populate voices (if they are ready yet). + * @returns {[type]} [description] + */ + initialize() { + this.initialized = true; + + // try to populate voice options first + this.populateVoices(); + } + + /** + * Get the available voices for the synth, and set to default. + * @rivate + */ + populateVoices() { + this.voices = this.synth.getVoices(); + this.voiceProperty.set( this.voices[ 0 ] ); + } + + speak( utterThis ) { + if ( this.initialized ) { + const utterance = new SpeechSynthesisUtterance( utterThis ); + utterance.voice = this.voiceProperty.value; + utterance.pitch = this.voicePitchProperty.value; + utterance.rate = this.voiceRateProperty.value; + + this.synth.speak( utterance ); + } + } +} + +const webSpeaker = new WebSpeaker(); + +// @public +// @static +webSpeaker.Verbosity = Verbosity; + +export default inverseSquareLawCommon.register( 'WebSpeaker', webSpeaker );