Search code examples
javascriptiostimeraudiocontext

Repeat an audioContext oscillator every 5 seconds


I am trying to write a morse code trainer that produces a random two letter pattern every 5 seconds with the audiocontext recreated each loop, but I cannot figure out how to add code which will call for a repeated loop. I've tried setTimeout() setInterval(), but they both eliminate the audio.

Also, after pressing the button five times on the following code. I get the error

" TypeError: null is not an object (evaluating 'ctx.currentTime')"

 <!DOCTYPE html>
    <html>
    <head>
        <meta charset="UTF-8">
        <title></title>
    </head>
    <body>
    <button onclick = "startIt()">Play</button>
    <button onclick = "stopIt()">Stop</button>
    <h2>Morse Code</h2>

    <h1 id="demo"></h1>
    <h1 id="demo2"></h1>

    <script>
    var codeStream = '';
    var dot = 1.2 / 15;
    var text = "";
    var display = "";
    var k = 0;
    var alphabet = [["A",".-"],["B","-..."],["C","-.-."],["D","-.."],["E","."],["F","..-."],["G","--."],["H","...."],["I",".."],["J",".---"],
        ["K","-.-"],["L",".-.."],["M","--"],["N","-."],["O","---"],["P",".--."],["Q","--.-"],["R",".-."],["S","..."],["T","-"],["U","..-"],
        ["V","...-"],["W",".--"],["X","-..-"],["Y","-.--"],["Z","--.."],["1",".----"],["2","..---"],["3","...--"],["4","....-"],["5","....."],
        ["6","-...."],["7","--..."],["8","---.."],["9","----."],["0","-----"],[".",".-.-.-"],[",","--..--"],["?","..--.."],["'",".----."],["!","-.-.--"],
        ["/","-..-."],[":","---..."],[";","-.-.-."],["=","-...-"],["-","-....-"],["_","..--.-"],["\"",".-..-."],["@",".--.-."],["(","-.--.-"],[" ",""]];

    stopIt = function(){
                ctx.close();
                location.reload();
            }

    function nextGroup() {
            for (i = 0; i < 2; i++){                
                var randomLetter = Math.floor(Math.random() * 26);
                var code = alphabet[randomLetter][1] + " ";
                var character = alphabet[randomLetter][0];      
                display += code;                    
                text += character;                  
            }
        codeStream = display;       
    }

    function startIt(){     
            var AudioContext = window.AudioContext || window.webkitAudioContext;
            var ctx = new AudioContext();
            var t = ctx.currentTime;
            var oscillator = ctx.createOscillator();        
            oscillator.type = "sine";
            oscillator.frequency.value = 600;
            oscillator.start();
            var gainNode = ctx.createGain();

            nextGroup();
            console.log(codeStream);
            document.getElementById("demo").innerHTML = text;
            document.getElementById("demo2").innerHTML = codeStream;
            display = "";
            text = "";                      
            gainNode.gain.setValueAtTime(0, t);

            for (var i = 0; i < codeStream.length; i++) {
                switch(codeStream.charAt(i)) {
                    case ".":
                        gainNode.gain.setValueAtTime(1, t);
                        t += dot;
                        gainNode.gain.setValueAtTime(0, t);
                        t += dot;
                        break;
                    case "-":
                        gainNode.gain.setValueAtTime(1, t);
                        t += 3 * dot;
                        gainNode.gain.setValueAtTime(0, t);
                        t += dot;
                        break;
                    case " ":
                        t += 7 * dot;
                        break;
                }           
            }

                gainNode.gain.setValueAtTime(0, t);
                t += 50 * dot;          

            oscillator.connect(gainNode);
            gainNode.connect(ctx.destination);          
            codeStream = '';                    
        oscillator.stop(t);         
        }                   
    </script>   
    </body>
</html>

Solution

  • It looks like some of the issues are to do with scoping and state management of the oscillator. I wasn't able to reproduce the error you were seeing but the stopIt function certainly doesn't have access to ctx created in startIt.

    An alternative might be to, rather than recreate the context, oscillator and gain node on each run, create them once and reuse them instead. Demo here: http://jsfiddle.net/kts74g0x/

    The code:

    const ALPHABET = [
      ["A", ".-"],
      ...
      [" ",""]
    ];
    const DOT = 1;
    const DASH = 3;
    const NEXT = DOT;
    const SPACE = 7;
    const SPEED = 1.2 / 15;
    
    const AudioContext = window.AudioContext || window.webkitAudioContext;
    
    /**
     * Create a single audio context, oscillator and gain node and repeatedly
     * use them instead of creating a new one each time. The gain is just
     * silent most of the time.
     */
    const ctx = new AudioContext();
    const oscillator = ctx.createOscillator();
    const gainNode = ctx.createGain();
    oscillator.type = "sine";
    oscillator.frequency.value = 600;
    oscillator.connect(gainNode);
    oscillator.start();
    gainNode.connect(ctx.destination);
    gainNode.gain.value = 0;
    
    function playCodeStream(stream) {
      let t = ctx.currentTime;
      gainNode.gain.setValueAtTime(0, t);
      for (var i = 0; i < stream.length; i++) {
        switch(stream.charAt(i)) {
          case ".":
            gainNode.gain.setValueAtTime(1, t);
            t += DOT * SPEED;
            gainNode.gain.setValueAtTime(0, t);
            t += NEXT * SPEED;
            break;
          case "-":
            gainNode.gain.setValueAtTime(1, t);
            t += DASH * SPEED;
            gainNode.gain.setValueAtTime(0, t);
            t += NEXT * SPEED;
            break;
          case " ":
            t += SPACE * SPEED;
            break;
        }  
      }
    }
    
    /**
     * Set interval will wait initially for the period of
     * time before first triggering the function.
     */
    setInterval(() => { playCodeStream([
      ALPHABET.filter(v => v[0] === "H"),
      ALPHABET.filter(v => v[0] === "E"),
      ALPHABET.filter(v => v[0] === "L"),
      ALPHABET.filter(v => v[0] === "L"),
      ALPHABET.filter(v => v[0] === "O")
    ].join(" ")); }, 10000);
    

    Set interval returns an ID that can be passed to clearInterval to prevent future runs, the play button might start the interval and the stop button could clear it, for example.

    For iOS there are restrictions so that an AudioContext cannot play sound unless it is in response to a user interaction (https://hackernoon.com/unlocking-web-audio-the-smarter-way-8858218c0e09). We can get around the problem by adding a button.

    <button id="go">Go</button>
    

    And checking the state of the audio context / starting the interval in response to clicking this button (demo: http://jsfiddle.net/7gfnrubc/). The updated code:

    function next() {
      playCodeStream([
        ALPHABET.filter(v => v[0] === "H"),
        ALPHABET.filter(v => v[0] === "E"),
        ALPHABET.filter(v => v[0] === "L"),
        ALPHABET.filter(v => v[0] === "L"),
        ALPHABET.filter(v => v[0] === "O")
      ].join(" "));
    }
    
    function go() {
      if (ctx.state === 'suspended') {
        ctx.resume();
      }
      /**
       * Set interval will wait initially for the period of
       * time before first triggering the function. Can call
       * the function initially to start off.
       */
      next();
      setInterval(next, 10000);
    }
    
    const button = document.getElementById("go");
    button.addEventListener("click", go);