While the currently accepted answer works, there is a much better way to do this, based on the understanding that oscillators are not "sound sources", they're signal sources, and the best way to "get a sound" is not to start up (one or more) oscillators only once you need the sound, but to have them already running, and simply allowing their signals through, or blocking them, as needed.
As such, what you want to do really is to gate the signal: if you let it through, and its connected to an audio out, we'll hear it, and if you block it, we won't hear it. So even though you might think that using a gain node is "poor practice", that's literally the opposite of what it is. We absolutely want to use a gain node:
Signal → Volume control → Audio output
In this chain, we can let the signal run forever (as it's supposed to), and we can instead control playback using the volume control. For example, say we want to play a 440Hz beep whenever we click a button. We start by setting up our chain, once:
// the "audio output" in our chain:
const audioContext = new AudioContext();
// the "volume control" in our chain:
const gainNode = audioContext.createGain();
gainNode.connect(audioContext.destination);
gainNode.gain.setValueAtTime(0, audioContext.currentTime);
// the "signal" in our chain:
const osc = audioContext.createOscillator();
osc.frequency.value = 440;
osc.connect(gainNode);
osc.start();
And then in order to play a beep, we set the volume to 1 using the setTargetAtTime function, which lets us change parameters "at some specific time", with a (usually short) interval over which the value gets smoothly changed from "what it is" to "what we want it to be". Which we do because we don't want the kind of crackling/popping that we get when we just use setValueAtTime
: the signal is almost guaranteed to not be zero the exact moment we set the volume, so the speaker has to jump to the new position, giving those lovely cracks. We don't want those.
This also means that we're not building any new elements, or generators, there's no allocation or garbage collection overhead: we just set the values that control what kind of signal ultimately makes it to the audio destination:
const smoothingInterval = 0.02;
const beepLengthInSeconds = 0.5;
playButton.addEventListener(`click`, () => {
const now = audioContext.currentTime;
gainNode.gain.setTargetAtTime(1, now, smoothingInterval);
gainNode.gain.setTargetAtTime(0, now + beepLengthInSeconds, smoothingInterval);
});
And we're done. The oscillator's always running, much like in actual sound circuitry, using near-zero resources while it does so, and we control whether or not we can hear it by toggling the volume on and off.
And of course we can make this much more useful by encapsulating the chain in something that has its own play()
function:
const audioContext = new AudioContext();
const now = () => audioContext.currentTime;
const smoothingInterval = 0.02;
const beepLengthInSeconds = 0.5;
const beeps = [220,440,880].map(Hz => createBeeper(Hz));
playButton.addEventListener(`click`, () => {
const note = (beeps.length * Math.random()) | 0;
beeps[note].play();
});
function createBeeper(Hz=220, duration=beepLengthInSeconds) {
const gainNode = audioContext.createGain();
gainNode.connect(audioContext.destination);
gainNode.gain.setValueAtTime(0, now());
const osc = audioContext.createOscillator();
osc.frequency.value = Hz;
osc.connect(gainNode);
osc.start();
return {
play: (howLong=duration) => {
console.log(`playing ${Hz}Hz for ${howLong}s`);
trigger(gainNode.gain, howLong);
}
};
}
function trigger(parameter, howLong) {
parameter.setTargetAtTime(1, now(), smoothingInterval);
parameter.setTargetAtTime(0, now() + howLong, smoothingInterval);
}
<button id="playButton">play</button>
OscillatorNode
on eachstart()
setTargetAtTime
) as needed (and of course, some extra work using gain, compressor, and limiter nodes if you need to deal with multiple oscillators at the same time, so you don't blow up anyone's speakers)