1

I am using the Web Speech API to read out an array of words with a short delay between each one (a spelling test for my son!). I have defined an async function to speak a single word and used setTimeout() to delay the following word by 5 seconds. Everything is working as required, except when the START button is pressed immediately after the STOP button, before the 5 second timeout has resolved. This results in the whole array of words starting again, with the remaining words from the initial test threaded in between. I have tried to fix this by cancelling the setTimeout method and by disabling the START button while the timeout is active, but without success.

// initiate the synth
const synth = window.speechSynthesis;

// grab the UI elements
const startButton = document.querySelector("#start");
let started = false;
const stopButton = document.querySelector("#stop");
stopButton.disabled = true;

// listen to the stop button
stopButton.addEventListener("click", () => {
  startButton.disabled = false;
  stopButton.disabled = true;
  started = false;
  synth.cancel();
});

// get the voices
const voices = synth.getVoices();
const GBvoice = voices.filter((voice) => {
  return voice.lang == "en-GB";
});

// speak a single word
async function speakWord(word) {
  const utterThis = new SpeechSynthesisUtterance(word);
  utterThis.voice = GBvoice[1];
  utterThis.pitch = 1;
  utterThis.rate = 1;
  synth.speak(utterThis);
}

// define delay function
const addDelay = (t) => {
  return new Promise((resolve) => {
    setTimeout(resolve.bind(null), t);
  });
};

// define the spelling words
const words = ["column", "solemn", "autumn", "foreign", "crescent", "spaghetti", "reign", "fascinating", "whistle", "thistle"];

// problem - when start button is pressed during timeout, two lists of words are spoken
startButton.onclick = async function () {
  startButton.disabled = true;
  stopButton.disabled = false;
  started = true;

  for (let word of words) {
    await speakWord(word).then(addDelay.bind(null, 5000));

    if (!started) {
      break;
    }
  }
};
<button id="stop">Stop</button>
<button id="start">Start</button>
user3840170
  • 26,597
  • 4
  • 30
  • 62
  • 1
    It might be easier to add all utterances to the queue at once, then after each [utterance has ended](https://developer.mozilla.org/en-US/docs/Web/API/SpeechSynthesisUtterance/end_event) do [pause the synthesis](https://developer.mozilla.org/en-US/docs/Web/API/SpeechSynthesis/pause) for a break. That way, when you `.cancel()` the queue all utterance will get removed and won't continue later. (It does have the effect of not uttering words at a constant rate of 1 per 5s though, since it now depends on the length of the words - this may or may not be desirable) – Bergi Feb 26 '23 at 20:53
  • 1
    You'll need to store the timer id returned by `setTimeout`, then [clear it](https://developer.mozilla.org/en-US/docs/Web/API/clearTimeout). (You may however also want to reject the promise instead of keeping it pending forever, to ensure that the looping really stops) – Bergi Feb 26 '23 at 20:57

1 Answers1

3

It may seem a little over-engineered, but here’s the cancellation route done using the AbortController API.

For clarity of presentation (and because I have no working speech synthesis in my browser), all parts related to speech synthesis were removed and replaced with a bare console.log, but you should be able to put them back easily.

let abc = null;
let jobCounter = 0;

const sleep = ms => new Promise(ok => setTimeout(ok, ms));

document.getElementById('btn.play').addEventListener('click', async ev => {
  const job = ++jobCounter;
  abc?.abort();
  const myAbc = abc = new AbortController();

  for (let i = 1; i <= 5; ++i) {
    if (myAbc.signal.aborted)
      break;

    // await speakWord(...)
    console.log(`job #${job} says: ${i}`);

    await sleep(1000);
  }
});

document.getElementById('btn.stop').addEventListener('click', ev => {
  abc?.abort();
});
<button id="btn.play">PLAY</button>
<button id="btn.stop">STOP</button>

In essence: instead of using a bare flag variable, each ‘speech’ job creates a new object managing its own cancellation state, then puts that object in a global variable where it can be found by either button’s handler. Either button, when clicked, will cancel the current job, if there is one; the play button will then start a new job. This way, the ABA problem of the original approach is averted.

In this example you may be able to get away with replacing AbortController with something simpler (like a plain object with a cancelled property), but in the general case where you need to invoke other Web APIs (like fetch), you may need to have an AbortController at the ready after all.

user3840170
  • 26,597
  • 4
  • 30
  • 62
  • 1
    "*each ‘speech’ job creates [**it's own**] new object managing the cancellation state, then puts that object in a global variable*" - that's the real gist of it, well identified! +1 – Bergi Feb 26 '23 at 22:07