47

I am working on a program to convert text into morse code audio.

Say I type in sos. My program will turn this into the array [1, 1, 1, 0, 2, 2, 2, 0, 1, 1, 1]. Where s = dot dot dot (or 1,1,1), and o = dash dash dash (or 2,2,2). This part is quite easy.

Next, I have two sound files:

var dot = new Audio('dot.mp3');
var dash = new Audio('dash.mp3');

My goal is to have a function that will play dot.mp3 when it sees a 1, and dash.mp3 when it sees a 2, and pauses when it sees a 0.

The following sort of/ kind of/ sometimes works, but I think it's fundamentally flawed and I don't know how to fix it.

function playMorseArr(morseArr) {
  for (let i = 0; i < morseArr.length; i++) {
    setTimeout(function() {
      if (morseArr[i] === 1) {
        dot.play();
      }
      if (morseArr[i] === 2) {
        dash.play();
      }
    }, 250*i);
  }
}

The problem:

I can loop over the array, and play the sound files, but timing is a challenge. If I don't set the setTimeout() interval just right, if the last audio file is not done playing and the 250ms has elapsed, the next element in the array will be skipped. So dash.mp3 is longer than dot.mp3. If my timing is too short, I might hear [dot dot dot pause dash dash pause dot dot dot], or something to that effect.

The effect I want

I want the program to go like this (in pseudocode):

  1. look at the ith array element
  2. if 1 or 2, start playing sound file or else create a pause
  3. wait for the sound file or pause to finish
  4. increment i and go back to step 1

What I have thought of, but don't know how to implement

So the pickle is that I want the loop to proceed synchronously. I've used promises in situations where I had several functions that I wanted executed in a specific order, but how would I chain an unknown number of functions?

I also considered using custom events, but I have the same problem.

Cœur
  • 37,241
  • 25
  • 195
  • 267
dactyrafficle
  • 838
  • 8
  • 18
  • 5
    Do note that, in proper Morse code, "The letters of a word are separated by a space of duration equal to three dots, and the words are separated by a space equal to seven dots." (from Wikipedia) And a dash is three times the length of a dot. You may want a word space character. – trlkly Feb 04 '19 at 08:47
  • Timeouts aren't the best approach to this sort of problem. But if you must use them, _don't_ rely on the delay being precise. You'd get more consistent results by running a much smaller interval and measuring/accumulating the actual elapsed time on each iteration and then triggering things at the correct moment(s) based upon the actual amount of time elapsed. – aroth Feb 04 '19 at 09:26
  • possible duplicate of [How do I add a delay in a JavaScript loop?](https://stackoverflow.com/q/3583724/1048572) – Bergi Feb 04 '19 at 10:18

4 Answers4

48

Do not use HTMLAudioElement for that kind of application.

The HTMLMediaElements are by nature asynchronous and everything from the play() method to the pause() one and going through the obvious resource fetching and the less obvious currentTime setting is asynchronous.

This means that for applications that need perfect timings (like a Morse-code reader), these elements are purely unreliable.

Instead, use the Web Audio API, and its AudioBufferSourceNodes objects, which you can control with µs precision.

First fetch all your resources as ArrayBuffers, then when needed generate and play AudioBufferSourceNodes from these ArrayBuffers.

You'll be able to start playing these synchronously, or to schedule them with higher precision than setTimeout will offer you (AudioContext uses its own clock).

Worried about memory impact of having several AudioBufferSourceNodes playing your samples? Don't be. The data is stored only once in memory, in the AudioBuffer. AudioBufferSourceNodes are just views over this data and take up no place.

// I use a lib for Morse encoding, didn't tested it too much though
// https://github.com/Syncthetic/MorseCode/
const morse = Object.create(MorseCode);

const ctx = new (window.AudioContext || window.webkitAudioContext)();

(async function initMorseData() {
  // our AudioBuffers objects
  const [short, long] = await fetchBuffers();

  btn.onclick = e => {
    let time = 0; // a simple time counter
    const sequence = morse.encode(inp.value);
    console.log(sequence); // dots and dashes
    sequence.split('').forEach(type => {
      if(type === ' ') { // space => 0.5s of silence
        time += 0.5;
        return;
      }
      // create an AudioBufferSourceNode
      let source = ctx.createBufferSource();
      // assign the correct AudioBuffer to it
      source.buffer = type === '-' ? long : short;
      // connect to our output audio
      source.connect(ctx.destination);
      // schedule it to start at the end of previous one
      source.start(ctx.currentTime + time);
      // increment our timer with our sample's duration
      time += source.buffer.duration;
    });
  };
  // ready to go
  btn.disabled = false
})()
  .catch(console.error);

function fetchBuffers() {
  return Promise.all(
    [
      'https://dl.dropboxusercontent.com/s/1cdwpm3gca9mlo0/kick.mp3',
      'https://dl.dropboxusercontent.com/s/h2j6vm17r07jf03/snare.mp3'
    ].map(url => fetch(url)
      .then(r => r.arrayBuffer())
      .then(buf => ctx.decodeAudioData(buf))
    )
  );
}
<script src="https://cdn.jsdelivr.net/gh/mohayonao/promise-decode-audio-data@eb4b1322113b08614634559bc12e6a8163b9cf0c/build/promise-decode-audio-data.min.js"></script>
<script src="https://cdn.jsdelivr.net/gh/Syncthetic/MorseCode@master/morsecode.js"></script>
<input type="text" id="inp" value="sos"><button id="btn" disabled>play</button>
Kaiido
  • 123,334
  • 13
  • 219
  • 285
18

Audios have an ended event that you can listen for, so you can await a Promise that resolves when that event fires:

const audios = [undefined, dot, dash];
async function playMorseArr(morseArr) {
  for (let i = 0; i < morseArr.length; i++) {
    const item = morseArr[i];
    await new Promise((resolve) => {
      if (item === 0) {
        // insert desired number of milliseconds to pause here
        setTimeout(resolve, 250);
      } else {
        audios[item].onended = resolve;
        audios[item].play();
      }
    });
  }
}
CertainPerformance
  • 356,069
  • 52
  • 309
  • 320
  • What is the purpose of `undefined` at `audios`? – guest271314 Feb 04 '19 at 04:30
  • 1
    Just a placeholder, since OP's `morseArr` has `1` corresponding to the `dot` audio, and `2` corresponding to the `dash` audio (but no audio corresponding to `0`). Could've also done `const item = morseArr[i] - 1` and had only two elements in the `audios` array – CertainPerformance Feb 04 '19 at 04:31
  • You'll want to put the `setTimeout` in a separate promise, and the `if` outside of the promise constructor. – Bergi Feb 04 '19 at 10:19
  • @Bergi That's actually what I did originally, but I thought it resulted in unnecessarily repetitive code, so I changed it to this version - is there an issue with the conditional calling of `resolve`? – CertainPerformance Feb 04 '19 at 10:26
  • I wouldn't call one additional *`await new Promise((resolve) => {`* line "repetitive" yet :-) Your code works, but the more code you put inside a `new Promise` constructor the easier it becomes to mess it up (forgetting to `resolve`, getting exceptions in async callbacks, ...). I would even have factored the functionality out into separate `delay(t)` and `play(audio)` functions that return a promise. – Bergi Feb 04 '19 at 10:38
10

I will use a recursive approach that will listen on the audio ended event. So, every time the current playing audio stop, the method is called again to play the next one.

function playMorseArr(morseArr, idx)
{
    // Finish condition.
    if (idx >= morseArr.length)
        return;

    let next = function() {playMorseArr(morseArr, idx + 1)};

    if (morseArr[idx] === 1) {
        dot.onended = next;
        dot.play();
    }
    else if (morseArr[idx] === 2) {
        dash.onended = next;
        dash.play();
    }
    else {
        setTimeout(next, 250);
    }
}

You can initialize the procedure calling playMorseArr() with the array and the start index:

playMorseArr([1, 1, 1, 0, 2, 2, 2, 0, 1, 1, 1], 0);

A test example (Using the dummy mp3 files from Kaiido's answer)

let [dot, dash] = [
    new Audio('https://dl.dropboxusercontent.com/s/1cdwpm3gca9mlo0/kick.mp3'),
    new Audio('https://dl.dropboxusercontent.com/s/h2j6vm17r07jf03/snare.mp3')
];

function playMorseArr(morseArr, idx)
{
    // Finish condition.
    if (idx >= morseArr.length)
        return;

    let next = function() {playMorseArr(morseArr, idx + 1)};

    if (morseArr[idx] === 1) {
        dot.onended = next;
        dot.play();
    }
    else if (morseArr[idx] === 2) {
        dash.onended = next;
        dash.play();
    }
    else {
        setTimeout(next, 250);
    }
}

playMorseArr([1,1,1,0,2,2,2,0,1,1,1], 0);
Shidersz
  • 16,846
  • 2
  • 23
  • 48
2

async & await

Although they are used for asynchronous operations they can be used for synchronous tasks as well. You make a Promise for each function, wrap them in an async function, and then call them with await one at a time. The following is the documentation of the async function as a named function in the demo, the one in the actual demo is an arrow function but either way they are one in the same:

 /**
  * async function sequencer(seq, t)
  *
  * @param {Array} seq - An array of 0s, 1s, and 2s. Pause. Dot, and Dash respectively.
  * @param {Number} t - Number representing the rate in ms.
  */

Plunker

Demo

Note: If the Stack Snippet doesn't work, review the Plunker

<!DOCTYPE html>
<html>

<head>
  <style>
    html,
    body {
      font: 400 16px/1.5 Consolas;
    }
    
    fieldset {
      max-width: fit-content;
    }
    
    button {
      font-size: 18px;
      vertical-align: middle;
    }
    
    #time {
      display: inline-block;
      width: 6ch;
      font: inherit;
      vertical-align: middle;
      text-align: center;
    }
    
    #morse {
      display: inline-block;
      width: 30ch;
      margin-top: 0px;
      font: inherit;
      text-align: center;
    }
    
    [name=response] {
      position: relative;
      left: 9999px;
    }
  </style>
</head>

<body>
  <form id='main' action='' method='post' target='response'>
    <fieldset>
      <legend>Morse Code</legend>
      <label>Rate:
        <input id='time' type='number' min='300' max='1000' pattern='[2-9][0-9]{2,3}' required value='350'>ms
      </label>
      <button type='submit'>
        ➖
      </button>
      <br>
      <label><small>0-Pause, 1-Dot, 2-Dash (no delimiters)</small></label>
      <br>
      <input id='morse' type='number' min='0' pattern='[012]+' required value='111000222000111'>
    </fieldset>
  </form>
  <iframe name='response'></iframe>
  <script>
    const dot = new Audio(`https://od.lk/s/NzlfOTYzMDgzN18/dot.mp3`);
    const dash = new Audio(`https://od.lk/s/NzlfOTYzMDgzNl8/dash.mp3`);

    const sequencer = async(array, FW = 350) => {

      const pause = () => {
        return new Promise(resolve => {
          setTimeout(() => resolve(dot.pause(), dash.pause()), FW);
        });
      }
      const playDot = () => {
        return new Promise(resolve => {
          setTimeout(() => resolve(dot.play()), FW);
        });
      }
      const playDash = () => {
        return new Promise(resolve => {
          setTimeout(() => resolve(dash.play()), FW + 100);
        });
      }

      for (let seq of array) {
        if (seq === 0) {
          await pause();
        }
        if (seq === 1) {
          await playDot();
        }
        if (seq === 2) {
          await playDash();
        }
      }
    }

    const main = document.forms[0];
    const ui = main.elements;

    main.addEventListener('submit', e => {
      let t = ui.time.valueAsNumber;
      let m = ui.morse.value;
      let seq = m.split('').map(num => Number(num));
      sequencer(seq, t);
    });
  </script>
</body>

</html>
zer00ne
  • 41,936
  • 6
  • 41
  • 68
  • From my machine, at 250 ms rate, the "O" from "SOS" sound is like "cut" at the half of the second dash, and the third dash doesn't play correctly. Means that I hear something like this : 111_21__111 instead of 111_222_111 . If I decrease the rate, this gets worse. If I increase the rate it gets better : at about 350 ms each sounds plays clearly. – Pac0 Feb 05 '19 at 00:28
  • 1
    It's my crappy editing with Audacity. I didn't cut the MP3 file precisely and just eyeballed it. Notice I added 100ms to `playDash()` (hence 350ms is just right) . Updated to default of 350ms, thanks @Pac0 – zer00ne Feb 05 '19 at 01:30