如何在JavaScript中同步播放音频文件?

How do I play audio files synchronously in JavaScript?

我正在开发一个将文本转换为摩尔斯电码音频的程序。

假设我输入 sos。我的程序会将它变成数组 [1, 1, 1, 0, 2, 2, 2, 0, 1, 1, 1]。其中 s = dot dot dot(或 1,1,1)和 o = dash dash dash(或 2,2,2)。这部分很简单。

接下来,我有两个声音文件:

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

我的目标是拥有一个功能,当它看到 1 时播放 dot.mp3,当它看到 2 时播放 dash.mp3,并在它看到 2 时暂停看到 0.

以下某种/某种/有时有效,但我认为它存在根本性缺陷,我不知道如何修复它。

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);
  }
}

问题:

我可以遍历数组并播放声音文件,但计时是一个挑战。如果我没有将 setTimeout() 间隔设置得恰到好处,如果最后一个音频文件没有播放完并且 250ms 已经过去,数组中的下一个元素将被跳过。所以 dash.mp3dot.mp3 长。如果我的时间太短,我可能会听到 [dot dot dot pause dash dash pause dot dot dot] 或类似的声音。

我想要的效果

我希望程序像这样(伪代码):

  1. 查看ith数组元素
  2. 如果 12,开始播放声音文件或创建一个暂停
  3. 等待声音文件或暂停完成
  4. 增加 i 并返回到 步骤 1

想到了,不知道怎么实现

所以泡菜是我希望循环同步进行。我曾在有多个函数并希望按特定顺序执行的情况下使用 promises,但我该如何链接未知数量的函数?

我也考虑过使用自定义事件,但是我遇到了同样的问题

Audios 有一个您可以监听的 ended 事件,因此您可以 await 一个 Promise 在该事件触发时解析:

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();
      }
    });
  }
}

我将使用递归方法来监听音频 ended 事件。因此,每次当前播放的音频停止时,都会再次调用该方法播放下一首。

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() 的过程:

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

一个测试示例(使用 Kaiido 的回答中的虚拟 mp3 文件)

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);

请勿将 HTMLAudioElement 用于此类应用程序。

HTMLMediaElements 本质上是异步的,从 play() 方法到 pause() 方法以及明显的资源获取和不太明显的 currentTime 设置都是异步的。

这意味着对于需要完美计时的应用程序(如摩尔斯电码 reader),这些元素完全不可靠。

改为使用网络音频 API 及其 AudioBufferSourceNodes 对象,您可以以微秒的精度控制它们。

首先获取所有资源作为 ArrayBuffers,然后在需要时从这些 ArrayBuffers 生成并播放 AudioBufferSourceNodes。

您将能够开始同步播放这些内容,或者以比 setTimeout 更高的精度安排它们(AudioContext 使用自己的时钟)。

担心让多个 AudioBufferSourceNode 播放样本会影响内存?别这样数据在内存中只存储一次,在 AudioBuffer 中。 AudioBufferSourceNodes 只是对此数据的视图,不占任何位置。

// 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>

async & await

虽然它们用于异步操作,但它们也可以用于同步任务。您为每个函数创建一个 Promise,将它们包装在 async function 中,然后使用 await 一次调用它们。以下是演示中async function作为命名函数的文档,实际演示中的是箭头函数,但无论哪种方式它们都是相同的:

 /**
  * 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

演示

注意: 如果 Stack Snippet 不起作用,请查看 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>