网络音频时钟:让频率听起来正确

Web Audio Clock: getting frequency to sound right

尝试在 JS 中创建一个单一的振荡器,具有平滑、无咔哒声的频率音调,可以快速打开和关闭。 [Chris Wallace][1] 讨论的网络音频时序。 [Aqilah Misuary][2] 可以找到定时调度程序。此示例根据频率生成听起来正确的音调。但是,会出现明显的咔哒声。 [Stack Overflow][3] 上的帖子已解决点击问题。

第一个代码片段基于 Code Pen 版本(参见 link 2),将频率更改为 432 Hz。

第二个代码片段: 从这些资源中改编 JS(参见 links 2 和 3),添加斜坡,使用 432 Hz 频率,我可以点击停止,但我的频率声音现在变钝了,不再有正确的 432 Hz频响。我怀疑我做错了什么,没有正确使用时间或设置。我已经尝试了各种时间和设置,甚至尝试了“初始”而不是“指数”斜坡,但是 none 已经解决了这个问题:点击消失但频率声音现在变钝了,没有保留基于选择的频率,如果不使用斜坡,就会听到,留下咔哒声。我一定是做错了什么?

有什么方法可以在不减弱声音的情况下解决点击问题? [1]: https://www.html5rocks.com/en/tutorials/audio/scheduling/ [2]: https://codepen.io/aqilahmisuary/pen/ONEKVM [3]:

<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>Untitled Document</title>

</head>

<body>
<p><button name="button" id="startBtn">Start</button>
  <button name="button" id="stopBtn">Stop</button></p>
<p>Audio Context Current Time:</p>
<p><span id="clock"></span></p>
<p>nextNotetime:</p>
<p><span id="nextNote"></span></p>

<style>
span, p, button {
    font-family: 'Courier New', Courier, 'Lucida Sans Typewriter', 'Lucida Typewriter', monospace;
    font-size: 25px;
    font-weight: 1000;
    line-height: 26.4px;
    text-align: center;
}

span {
    font-weight: 200;
}
</style>

<script>
window.AudioContext = window.AudioContext || window.webkitAudioContext;

var audioContext = new AudioContext();
var nextNotetime = audioContext.currentTime;
var clock = document.getElementById("clock");
var nextNote = document.getElementById("nextNote");
var startBtn = document.getElementById("startBtn");
var stopBtn = document.getElementById("stopBtn");
var timerID;

setInterval(function(){ clock.innerHTML = audioContext.currentTime; }, 100);

function playSound(time) {
  
  var osc = audioContext.createOscillator();
  osc.connect(audioContext.destination);
  osc.frequency.value = 432;
  osc.start(time);
  osc.stop(time + 0.1);
  
};

function scheduler() {

    while(nextNotetime < audioContext.currentTime + 0.1) {
        
        nextNotetime += 0.5;
        nextNote.innerHTML = nextNotetime;
        playSound(nextNotetime);
    }

   timerID = window.setTimeout(scheduler, 50.0);
}

startBtn.addEventListener('click', function() {

    scheduler();

  }, false);

stopBtn.addEventListener('click', function() {

    clearTimeout(timerID);

  }, false);

if(audioContext.state === 'suspended'){
  audioContext.resume();
};
</script>

</body>
</html>

<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>Untitled Document</title>
</head>
<body>
<p><button name="button" id="startBtn">Start</button>
  <button name="button" id="stopBtn">Stop</button></p>
<p>Audio Context Current Time:</p>
<p><span id="clock"></span></p>
<p>nextNotetime:</p>
<p><span id="nextNote"></span></p>
<style>
span, p, button {
    font-family: 'Courier New', Courier, 'Lucida Sans Typewriter', 'Lucida Typewriter', monospace;
    font-size: 25px;
    font-weight: 1000;
    line-height: 26.4px;
    text-align: center;
}
span {
    font-weight: 200;
}
</style>
<script>
window.AudioContext = window.AudioContext || window.webkitAudioContext;

var audioContext = new AudioContext();
var nextNotetime = audioContext.currentTime;
var clock = document.getElementById("clock");
var nextNote = document.getElementById("nextNote");
var startBtn = document.getElementById("startBtn");
var stopBtn = document.getElementById("stopBtn");
var timerID;
var gainNode = audioContext.createGain();
var osc;
var rampDuration = 0.3;
gainNode.connect(audioContext.destination);
setInterval(function(){ clock.innerHTML = audioContext.currentTime; }, 100);
function playSound(time) {  
  osc = audioContext.createOscillator();
  osc.connect(gainNode);
  osc.frequency.value = 432;
  osc.type = "sine";
  gainNode.gain.setValueAtTime(1, audioContext.currentTime);
  gainNode.gain.linearRampToValueAtTime(0.0001, audioContext.currentTime + rampDuration);
  gainNode.gain.linearRampToValueAtTime(1, audioContext.currentTime + rampDuration);
  osc.start(time);
  osc.stop(time + 0.01);  
};
function scheduler() {
    while(nextNotetime < audioContext.currentTime + 0.1) {        
        nextNotetime += 0.5;
        nextNote.innerHTML = nextNotetime;
        playSound(nextNotetime);
    }
   timerID = window.setTimeout(scheduler, 50.0);
}
startBtn.addEventListener('click', function() {
    scheduler();
  }, false);
stopBtn.addEventListener('click', function() {
    clearTimeout(timerID);
  }, false);
if(audioContext.state === 'suspended'){
  audioContext.resume();
};
</script>
</body>
</html>

根据评论,我们本质上是在处理一个需要 精确 计时的短重复循环,因此 an AudioBufferSourceNode 是我们选择的武器:

It's especially useful for playing back audio which has particularly stringent timing accuracy requirements, such as for sounds that must match a specific rhythm

不幸的是,这也意味着我们需要亲自动手并编写一些 DSP 代码来合成该缓冲区,但老实说并没有那么糟糕(特别是因为我们只能处理浮点数,而不是原始 PCM 缓冲区...).

为了避免咔嗒声(振荡器在中间阶段被切断,就像过去一样),我们利用了正弦波总是从零开始的事实,我们渲染了一个声音循环,所以所有我们需要做的是确保浪潮的结束不会突然停止。我们通过稍微调整音调的长度来做到这一点,以确保最后一个可听到的样本非常接近于零。

这里的例子有几个按钮来演示不同的参数。您可以将它们连接到 UI 个组件中。

var audioContext = new AudioContext();
var gainNode = audioContext.createGain();
gainNode.connect(audioContext.destination);
var playerNode = null; // Initialized later.

function createLoop(audioContext, toneFrequency, toneDuration, loopDuration) {
  const arrayBuffer = audioContext.createBuffer(
    1,
    audioContext.sampleRate * loopDuration,
    audioContext.sampleRate,
  );
  const channel = arrayBuffer.getChannelData(0); // mono, only deal with single channel
  const toneDurationInSamples = Math.floor(audioContext.sampleRate * toneDuration);
  const phasePerSample = (Math.PI * 2 * toneFrequency) / audioContext.sampleRate;
  let audible = true;
  for (let i = 0; i < arrayBuffer.length; i++) {
    if (audible) {
      let value = Math.sin(phasePerSample * i);
      channel[i] = value;
      // We might slightly overshoot the tone's requested duration
      // but we need to wait for the oscillation to be near zero
      // to avoid an audible click (when the signal "snaps" from an arbitrary
      // value to zero).
      if (i >= toneDurationInSamples && Math.abs(value) < 0.02) {
        audible = false;
      }
    } else {
      channel[i] = 0; // Silence
    }
  }
  return arrayBuffer;
}

function go(hz, length) {
  halt(); // Remove the old player node. We couldn't modify the buffer anyway.
  playerNode = audioContext.createBufferSource();
  playerNode.loop = true;
  playerNode.connect(gainNode);

  const buf = createLoop(audioContext, hz, length / 5, length);
  playerNode.buffer = buf;
  playerNode.start();
  audioContext.resume();
}

function halt() {
  if (playerNode) {
    playerNode.stop();
    playerNode.disconnect();
    playerNode = null;
  }
}
function handleVolumeChange(volume) {
  gainNode.gain.setValueAtTime(volume, audioContext.currentTime);
}
Since an Audio Context can't start with interaction, you'll need to hit a button...

<br />

<button onclick="go(432, 0.5)">Go at 432 hz</button>
<button onclick="go(880, 0.3)">Go faster and at 880 hz</button>
<button onclick="go(1250, 0.1)">Go really fast and high</button>
<button onclick="halt()">Stop going</button>

<br />

Volume: <input type="range" min="0" max="1" value="1" step="0.01" onInput="handleVolumeChange(event.target.valueAsNumber)">