.wrap
  h1 Talker
  #app
    //- .box.block(aria-label="error", hidden)
    //-   p Sorry, your browser doesn't support <a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/speechSynthesis">Speech Synthesis</a>.
    .box.block(aria-label="instructions")
      h2 Instructions
      ul
        li Press 's' or 'Enter' to play the current text in the textbox.
        li Press 'x' to cancel the current playback.
        li Shift + click on the close icon (x) on phrases to remove them without the confirmation dialog.
        li Shift + click on a voice button to select that voice and also play the phrase currently in the text box.
        //- li Press 'p' to pause the current playback.
        //- li Press 'r' to resume the current playback.
    .box.block(aria-label="voices")
      h2 Voices
      .box.current-voice
      .voices
    .box.block(aria-label="phrases")
      h2 Phrases
      label(for="phrase") Enter Phrase
      textarea(id="phrase")
      button(id="phraseButton") Say It
      .phrases
View Compiled
$font-text: "Roboto", "Lato", sans-serif
$background-color: #f0faff
$border-color: #a2a2a2
$error-color: lighten(red, 10%)

[hidden]
  display: none
  
html
  font-size: 62.5%
  box-sizing: border-box
  
body
  font-size: 18px
  font-family: $font-text
  line-height: 1.5em
  color: #333
  background-color: $background-color
  
*, *::before, *::after
  box-sizing: inherit

.wrap
  max-width: 800px
  margin: 0 auto

button, input, textarea, select
  font-size: 1.6rem
  font-family: $font-text
  display: inline-block
  margin: .4rem 1rem
  padding: .5rem 1rem
  border-radius: 6px
  background-color: white
  color: #333
  border: 1px solid $border-color
  outline: none
  text-align: left
  
textarea
  display: block
  width: 100%
  min-height: 15vh
  
button
  transition: all .33s linear
  cursor: pointer
  position: relative
  &:hover
    background-color: #333
    color: white
    border-color: #333
  
  .close
    text-align: center
    &::before
      content: 'x'
      padding: 1px 0 0
      display: inline-block
    
    position: absolute
    top: -0.8em
    right: -0.8em
    height: 1.4em
    width: 1.4em
    display: block
    // padding: 4px
    background-color: $error-color
    color: white
    font-size: 1.4rem
    line-height: 1em
    border-radius: 50%
    cursor: pointer
    
    &:hover
      background-color: red
  
.box
  display: inline-block
  margin: 1rem 0
  padding: .5rem 1rem
  border-radius: 6px
  border: 1px solid $border-color
  background-color: darken($background-color, 10%)
  
  &.block
    display: block
    width: 100%
    
  &[aria-label="error"]
    background-color: lighten($error-color, 20%)

  &[aria-label="voices"]
    background-color: darken($background-color, 4%)
    
  &[aria-label="instructions"]
    font-size: 1.4rem

  &[aria-label="phrases"]
    background-color: lighten(green, 67%)
    
View Compiled
var Talker = (function ($) {
  var speech = window.speechSynthesis, 
      ret = {
        nope: false
      }, 
      keyDown = {},
      voices = [],
      selectedVoice = null,
      phrases = [
        "Guten Morgen! Was gibt es zum Frühstück?",
        "Okey dokey.",
        "Hello world.",
        "Cheeseburger in paradise.",
      ],
      body,
      app, 
      phrasesElement, 
      voicesElement, 
      selectedVoiceElement, 
      phraseTextbox,
      phraseButton,
      errorBlock
  
  if (typeof speech === 'undefined') {
    console.error("window.speechSynthesis doesn't exist.  Please try a different browser that supports this feature.")
    ret.nope = true
  }

  function populateVoiceList() {
    var newVoices = speech.getVoices()
    if (!selectedVoice && newVoices && newVoices.length > 0) {
      voices = newVoices
      selectedVoice = voices[0]
      render()
    }
  }

  function speak(phrase) {
    if ( ! (phrase && /\w/i.test(phrase) && selectedVoice)) return
    speech.cancel()
    let utterance = new SpeechSynthesisUtterance(phrase)
    utterance.voice = selectedVoice
    speech.speak(utterance)
  }
  
  function render() {
    voicesElement.html('')
    phrasesElement.html('')
    selectedVoiceElement.html('')
    
    if (ret.nope) {
      $(".box").hide()
      errorBlock.show()
      return
    }
    
    for (let i in phrases) {
      let phrase = phrases[i]
      let button = $("<button></button>")
      button.data('phrase', phrase)
      button.text(phrase)
      button.append('<span class="close"/>')
      phrasesElement.append(button)
    }    
    for (let i in voices) {
      let voice = voices[i]
      if (!voice || !voice.name) {
        continue
      }
      let voiceEl = $("<button />")
      voiceEl.html(voice.name)
      voiceEl.data('voice', voice)
      if (selectedVoice && selectedVoice.name) {
        
      }
      voicesElement.append(voiceEl)
    }
    if (selectedVoice && selectedVoice.name) {
      selectedVoiceElement.html(`Current voice: <strong>${selectedVoice.name}</strong>`)
    }
  }
  
  function onPhraseClick(e) {
    var me = $(e.currentTarget)
    e.preventDefault()
    var phrase = me.data('phrase')
    if (phrase) {
      phraseTextbox.val(phrase)
      speak(phrase)
    }
  }
  
  function onVoiceClick(e) {
    var me = $(e.currentTarget)
    e.preventDefault()
    var voice = me.data('voice')
    if (voice && voice.name) {
      selectedVoice = voice
    }
    render()
    if (keyDown['Shift']) {
      let phrase = phraseTextbox.val()
      if (phrase && typeof phrase === 'string') {
        speak(phrase)
      }
    }
  }
  
  function onPhraseButtonClick(e) {
    e.preventDefault()
    processNewPhrase()
  }
  
  function onPhraseCloseClick(e) {
    e.preventDefault()
    e.stopPropagation()
    var phrase = $(e.currentTarget).parent().data('phrase')
    if (phrase && (keyDown['Shift'] || confirm('Really remove this phrase?'))) {
      phrases = phrases.filter((el) => el !== phrase)
      render()
    }
  }
  
  function processNewPhrase() {
    var newPhrase = phraseTextbox.val()
    if (newPhrase && /\w/i.test(newPhrase)) {
      if (!phrases.includes(newPhrase)) {
        phrases.unshift(newPhrase)
      }
      render()
      speak(newPhrase)
    }
  }
  
  function onPhraseTextboxKeyPress(e) {
    e.stopPropagation()
    // console.log('onPhraseTextboxKeyPress', e)
    switch (e.keyCode) {
      case 13:
        e.preventDefault()
        processNewPhrase()
        break;
    }
  }
  
  function onAppKeyPress(e) {
    // console.log(e)
    switch (e.key) {
      case 's':
      case 'Enter':
        let phrase = phraseTextbox.val()
        if (phrase) {
          speak(phrase)
        }
        break
      case 'x':
        speech.cancel()
        break
    }
  }
  
  function onAppKeyDown(e) {
    // console.log(`key down: ${e.key}`)
    keyDown[e.key] = true
  }  
  function onAppKeyUp(e) {
    keyDown[e.key] = false
  }
  
  function ready() {
    app = $("#app")
    body = $('body')

    errorBlock = app.find('.block[aria-label="error"]')
    phrasesElement = app.find('.phrases')
    voicesElement = app.find('.voices')
    selectedVoiceElement = app.find('.current-voice')
    phraseTextbox = app.find('#phrase')
    phraseButton = app.find('#phraseButton')
    
    if (ret.nope) {
      render()
      return
    }

    body.on('keypress', onAppKeyPress)
    body.on('keydown', onAppKeyDown)
    body.on('keyup', onAppKeyUp)
    
    app.on('click', '.phrases button', onPhraseClick)
    app.on('click', '.phrases button .close', onPhraseCloseClick)
    app.on('click', '.voices button', onVoiceClick)
    phraseTextbox.on('keypress', onPhraseTextboxKeyPress)
    phraseButton.on('click', onPhraseButtonClick)
    
    populateVoiceList()
    speech.onvoiceschanged = populateVoiceList  
    
    render()
  }
  
  $(ready)
  return ret
})(jQuery)
View Compiled

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/jquery/3.1.0/jquery.min.js