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

48

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

比如说我输入了 sos。我的程序会把它转换成数组 [1, 1, 1, 0, 2, 2, 2, 0, 1, 1, 1]。其中 s = 点 点 点(或者 1,1,1),o = 划 划 划(或者 2,2,2)。这部分很容易实现。

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

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

我的目标是要有一个函数,当它看到1时播放dot.mp3 ,当它看到2时播放dash.mp3 ,当它看到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. 查看第 i 个数组元素
  2. 如果是 12,开始播放声音文件,否则创建暂停
  3. 等待声音文件或暂停完成
  4. 增加 i 并返回到步骤1

我所考虑的,但不知道如何实现的内容

因此,困难在于我想使循环同步进行。在我需要特定顺序执行多个函数的情况下,我使用过 promises,但是如何链接未知数量的函数?

我还考虑使用自定义事件,但我有同样的问题。


5
请注意,在标准莫尔斯电码中,“单词中的字母之间用持续时间等于三个点的空格隔开,单词之间用持续时间等于七个点的空格隔开。”(来自维基百科)一个破折号的长度是一个点的三倍。您可能需要一个单词空格字符。 - trlkly
超时并不是解决这种问题的最佳方法。但如果你必须使用它们,请不要依赖于延迟的精确性。通过运行更小的间隔并在每次迭代中测量/累积实际经过的时间,然后根据实际经过的时间触发正确的时刻,您可以获得更一致的结果。 - aroth
可能是如何在JavaScript循环中添加延迟?的重复问题。 - Bergi
4个回答

49
不要在这种应用程序中使用HTMLAudioElement。 HTMLMediaElements天生是异步的,从play()方法到pause()方法,再到明显的资源获取和不太明显的currentTime设置都是异步的。这意味着对于需要完美时间控制(如摩尔斯电码读取器)的应用程序,这些元素是纯粹不可靠的。相反,请使用Web Audio API及其AudioBufferSourceNodes对象,您可以以微秒精度进行控制。首先将所有资源作为ArrayBuffers提取,然后在需要时从这些ArrayBuffers生成并播放AudioBufferSourceNodes。您将能够同步开始播放这些音频,或者以比setTimeout更高的精度安排它们(AudioContext使用自己的时钟)。担心同时有几个AudioBufferSourceNodes播放样本会对内存产生影响吗?不用担心。数据仅在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>


这个 Stack Snippet 给我一个“脚本错误”(Safari 12 OSX) - Lightness Races in Orbit
2
-.-. --- --- .-.. / -... . - - . .-. / -. --- .-- / - .... .- -. -.- ... (这是莫尔斯电码,翻译为)CORE BETTER NOW THANKS - Lightness Races in Orbit
1
@LightnessRacesinOrbit 当然可以很容易地完成,但我担心它会在答案本身中造成更多的混乱,所以这里是一个小测试 - Kaiido
我对这个答案非常满意。虽然在回答我的问题时提供了很多有用的信息,但你的答案确实为我揭示了一些 JavaScript 中的新思路。 - dactyrafficle
你可以用它制作一些很棒的节奏。那种“sos”的感觉很酷。试试跟着“abcabc”来演奏。我能得到一个“啥?啥?”吗? - B Layer
显示剩余2条评论

18

Audio元素拥有一个ended事件,您可以侦听该事件,以便您可以await一个Promise,该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();
      }
    });
  }
}

undefinedaudios 中的目的是什么? - guest271314
1
只是一个占位符,因为OP的“morseArr”中的“1”对应于“点”音频,“2”对应于“破折号”音频(但没有与“0”相对应的音频)。也可以使用“const item = morseArr [i] - 1”并且在“audios”数组中只有两个元素。 - CertainPerformance
你需要将 setTimeout 放在一个单独的 Promise 中,而将 if 放在 Promise 构造函数之外。 - Bergi
@Bergi,那实际上是我最初所做的,但我认为它导致了不必要的重复代码,所以我将其更改为这个版本 - 条件调用resolve有问题吗? - CertainPerformance
我不认为再加一行 await new Promise((resolve) => { 就算是“重复”的了 :-) 你的代码可以工作,但是你在 new Promise 构造函数中放置的代码越多,就越容易出错(忘记 resolve、在异步回调中出现异常等)。我甚至会将功能分解成单独的 delay(t)play(audio) 函数,它们返回一个 Promise。 - Bergi

10

我将使用递归方法来监听音频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);


2

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>


从我的机器上,以250毫秒的速率播放"SOS"信号时,第二个短划线的"O"音在中间被截断了,第三个长划线的声音也无法正确播放。这意味着我听到的是111_21__111而不是111_222_111。如果我降低速率,情况会变得更糟。如果我增加速率,情况会变得更好:大约在350毫秒时每个声音都能清晰地播放。 - Pac0
1
这是我用Audacity编辑的糟糕结果。我没有精确地剪切MP3文件,只是凭眼测量。请注意,我在playDash()中添加了100毫秒(因此350毫秒刚好合适)。感谢@Pac0,已更新为默认值350毫秒。 - zer00ne

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接