如何在 JavaScript 中同步播放音频文件?
Posted
技术标签:
【中文标题】如何在 JavaScript 中同步播放音频文件?【英文标题】:How do I play audio files synchronously in JavaScript? 【发布时间】:2019-06-27 19:53:19 【问题描述】:我正在开发一个将文本转换为摩尔斯电码音频的程序。
假设我输入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
,当它看到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.mp3
比dot.mp3
长。如果我的时间太短,我可能会听到[dot dot dot pause dash dash pause dot dot dot]
,或类似的东西。
我想要的效果
我希望程序像这样运行(在伪代码中):
-
查看
ith
数组元素
如果是1
或2
,则开始播放声音文件或创建暂停
等待声音文件或暂停完成
增加i
并返回步骤1
想到的,不知道如何实现
所以泡菜是我希望循环同步进行。我曾在我想要以特定顺序执行的多个函数的情况下使用 Promise,但我将如何链接未知数量的函数?
我也考虑过使用自定义事件,但我遇到了同样的问题。
【问题讨论】:
请注意,在正确的摩尔斯电码中,“一个单词的字母由一个持续时间等于三个点的空格分隔,并且单词由一个等于七个点的空格分隔。” (来自***)破折号是点长度的三倍。您可能需要一个空格字符。 超时并不是解决此类问题的最佳方法。但如果您必须使用它们,不要依赖精确的延迟。通过运行更小的间隔并测量/累积每次迭代的实际经过时间,然后根据实际经过的时间量在正确的时刻触发事件,您将获得更一致的结果。 How do I add a delay in a javascript loop?的可能重复 【参考方案1】:不要将 htmlAudioElement 用于此类应用程序。
HTMLMediaElements 本质上是异步的,从play()
方法到pause()
的所有内容,以及通过明显的资源获取和不太明显的currentTime
设置都是异步的。
这意味着对于需要完美计时的应用程序(如摩尔斯电码阅读器),这些元素完全不可靠。
改为使用 Web Audio API 及其 AudioBufferSourceNodes 对象,您可以以 µs 精度控制这些对象。
首先以 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>
【讨论】:
这个堆栈片段给了我一个“脚本错误”(Safari 12 OSX) @LightnessRacesinOrbit 是的,Safari 仍然不支持 decodeAudioData => Promise,对不起,我忘记了 polyfill,他们仍然会为 AudioContext 构造函数添加前缀。 -.-。 --- --- .-.. / -.. - - 。 .-. / -。 --- .-- / - .... .- -。 -.- ... @LightnessRacesinOrbit 确定它可以很容易地完成,但我担心它会比答案本身产生更多的混乱,所以here it is as a fiddle。 非常好。我认为这个钩子可以用作通用解决方案的一部分。【参考方案2】:Audio
s 有一个可以监听的 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();
);
【讨论】:
undefined
audios
的目的是什么?
只是一个占位符,因为OP的morseArr
有1
对应dot
音频,2
对应dash
音频(但没有音频对应0
) .也可以完成const item = morseArr[i] - 1
并且在audios
数组中只有两个元素
您需要将setTimeout
放在单独的promise 中,并将if
放在promise 构造函数之外。
@Bergi 这实际上是我最初所做的,但我认为它导致了不必要的重复代码,所以我将其更改为这个版本 - resolve
的条件调用是否有问题?跨度>
我不会再调用一个 await new Promise((resolve) =>
行“重复” :-) 您的代码有效,但是您在 new Promise
构造函数中放入的代码越多更容易搞砸(忘记resolve
,在异步回调中获取异常,...)。我什至会将功能分解为单独的 delay(t)
和 play(audio)
返回承诺的函数。【参考方案3】:
我将使用递归方法来监听音频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's answer的虚拟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);
【讨论】:
【参考方案4】: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
演示
注意:如果堆栈片段不起作用,请查看 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”声音在第二个破折号的一半处就像“cut”,而第三个破折号播放不正确。意味着我听到这样的话: 111_21__111 而不是 111_222_111 。如果我降低利率,情况会变得更糟。如果我提高速率,它会变得更好:在大约 350 毫秒时,每个声音都能清晰播放。 这是我用 Audacity 进行的糟糕编辑。我没有精确地剪切 MP3 文件,只是盯着它看。请注意,我在playDash()
中添加了 100 毫秒(因此 350 毫秒正好)。已更新为默认 350 毫秒,感谢 @Pac0以上是关于如何在 JavaScript 中同步播放音频文件?的主要内容,如果未能解决你的问题,请参考以下文章
如何将两个音频文件合并为一个并在javascript中合并后播放