Skip to content

Commit

Permalink
Create a webSpeaker and options dialog to support new self-voicing pr…
Browse files Browse the repository at this point in the history
  • Loading branch information
jessegreenberg committed Mar 3, 2020
1 parent fcd11ba commit 31777a4
Show file tree
Hide file tree
Showing 3 changed files with 213 additions and 1 deletion.
7 changes: 6 additions & 1 deletion js/ISLCQueryParameters.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
Expand Down
118 changes: 118 additions & 0 deletions js/view/WebSpeechDialogContent.js
Original file line number Diff line number Diff line change
@@ -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;
89 changes: 89 additions & 0 deletions js/view/webSpeaker.js
Original file line number Diff line number Diff line change
@@ -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.<Verbosity>} - 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 );

0 comments on commit 31777a4

Please sign in to comment.