Logo loophole letters

Real Time Synthesis

code

Last time, I was getting into generating buffers by hand to do synthesis. This approach is good to learn the basics of sample manipulation, but one obvious limitation is that it does not run in real time, as each buffer is pregenerated for a specified number of seconds and then played back:

It would be nice if we could repeatedly regenerate buffers to create endless streams of audio. Here is what we’ll have in the end:

Let’s solve this problem in a very naive way first..

Disclaimer

All of the below implementations are NOT how you would normally do this! The correct solution for this problem is called AudioWorklet, which I want to investigate after having tried this “naive” but easier to follow approach.

Temporal Recursion

First, we need an endless loop:

function timesink(fn, duration = 1000) {
  let block = 0;
  function sink() {
    fn(block++) && setTimeout(sink, duration);
  }
  sink();
}

The above snippet implements a simple temporal recursion, where the function passed to timesink is called repeatedly. When the function returns false, the recursion stops. This is just a more fancy way to do setInterval. Let’s use it:

timesink((block) => {
  console.log("block", block);
  return block <= 5;
});

If you copy the above snippet, your console should log the block numbers accordingly..

Buffer Chaining

Using the loop, we can then chain multple buffer together like this:

document.addEventListener("click", function handleClick() {
  const ac = new AudioContext();
  const seconds = 1;
  const nSamples = seconds * ac.sampleRate;
  let t = 0;
  let playhead = ac.currentTime + 0.1;

  const buffer = ac.createBuffer(1, nSamples, ac.sampleRate);
  const samples = new Float32Array(nSamples);

  timesink((block) => {
    const getSample = () => Math.sin(++t / 20);
    for (let i = 0; i < samples.length; i++) {
      samples[i] = getSample(i); // call fn on each sample
    }
    // buffer.copyToChannel(samples, 0);
    buffer.getChannelData(0).set(samples);
    // play the buffer
    const source = ac.createBufferSource();
    source.buffer = buffer;
    source.connect(ac.destination);
    playhead < ac.currentTime && console.log("TOO LATE...");
    console.log("playhead", playhead);
    source.start(playhead);
    playhead += source.buffer.duration;
    return block < 10;
  }, seconds * 1000);
  document.removeEventListener("click", handleClick);
});
  • The t variable counts the absolute sample number as time progresses
  • The playhead variable makes sure the next buffer starts exactly where the last one left off.

If you let that run for a while, you might get TOO LATE... logged to the console, because the timeout clock is not synced to the audio context clock…

We can solve that problem by replacing timesink with the “Tale of 2 Clocks” scheduling mechanism from an earlier post.

show full clock code
// generalized "Tale of 2 Clocks" scheduling
function createClock(
  getTime,
  callback, // called slightly before each cycle
  duration = 0.05, // duration of each cycle
  interval = 0.1, // interval between callbacks
  overlap = 0.1 // overlap between callbacks
) {
  let tick = 0; // counts callbacks
  let phase = 0; // next callback time
  let precision = 10 ** 4; // used to round phase
  let minLatency = 0.01;
  const setDuration = (setter) => (duration = setter(duration));
  overlap = overlap || interval / 2;
  const onTick = () => {
    const t = getTime();
    const lookahead = t + interval + overlap; // the time window for this tick
    if (phase === 0) {
      phase = t + minLatency;
    }
    // callback as long as we're inside the lookahead
    while (phase < lookahead) {
      phase = Math.round(phase * precision) / precision;
      phase >= t && callback(phase, duration, tick);
      phase < t && console.log("TOO LATE", phase); // what if latency is added from outside?
      phase += duration; // increment phase by duration
      tick++;
    }
  };
  let intervalID;
  const start = () => {
    clear(); // just in case start was called more than once
    onTick();
    intervalID = setInterval(onTick, interval * 1000);
  };
  const clear = () => intervalID !== undefined && clearInterval(intervalID);
  const pause = () => clear();
  const stop = () => {
    tick = 0;
    phase = 0;
    clear();
  };
  const getPhase = () => phase;
  // setCallback
  return {
    setDuration,
    start,
    stop,
    pause,
    duration,
    interval,
    getPhase,
    minLatency,
  };
}

// clock specific for AudioContext
function getClock(ac, fn, interval) {
  const getTime = () => ac.currentTime;
  const clock = createClock(
    getTime,
    // called slightly before each cycle
    fn,
    interval // duration of each cycle
  );
  return clock;
}

Using that clock, we can chain buffers like this:

function bufferclock(ac, fn) {
  let playhead;
  const interval = 0.1;
  const nSamples = interval * ac.sampleRate;
  const buffer = ac.createBuffer(1, nSamples, ac.sampleRate);
  const samples = new Float32Array(nSamples);

  const clock = getClock(
    ac,
    () => {
      // fill buffer
      for (let i = 0; i < samples.length; i++) {
        samples[i] = fn(i); // call fn on each sample
      }
      buffer.getChannelData(0).set(samples);

      // play buffer
      const source = ac.createBufferSource();
      source.buffer = buffer;
      source.connect(ac.destination);
      playhead = playhead || ac.currentTime;
      playhead < ac.currentTime && console.log("OH NO...");
      source.start(playhead);
      playhead += source.buffer.duration;
      source.stop(playhead);
    },
    interval
  );
  return clock;
}

… and use it:

document.addEventListener("click", function handleClick() {
  const ac = new AudioContext();
  let t = 0;
  const clock = bufferclock(ac, () => Math.sin(++t / 20));

  clock.start();
  setTimeout(() => {
    clock.stop();
  }, 10000);
  document.removeEventListener("click", handleClick);
});

Again, you can copy that to your console to hear 10 seconds of a hopefully crackle-free sine tone.

This is still not the state of the art solution, but it’s fun nonetheless + works for simpler stuff.

Live Coding DSP

With the above implementation, we can already live code our DSP:

Press ctrl+enter to update while it’s playing! This editor also supports the abstractions from the buffer post:

Bonus: Bytebeat

That editor can be used to play bytebeat / floatbeat:

Classic Bytebeat uses numbers from 0 to 255. We can convert them to the range [-1,1] like this:

const byte2floatbeat = (max255) => (max255 & 255) / 127.5 - 1;

Conclusion

This was a fun experiment.. I originally planned to conclude with AudioWorklets, but I guess that’ll happen in a future post…

Bonus 2: Source Code

show full source code

copy this code into your browser console and click into the page!

minified:

function createClock(e,t,$=.05,r=.1,n=.1){let c=0,o=0,_=e=>$=e($);n=n||r/2;let l=()=>{let _=e(),l=_+r+n;for(0===o&&(o=_+.01);o<l;)(o=Math.round(1e4*o)/1e4)>=_&&t(o,$,c),o<_&&console.log("TOO LATE",o),o+=$,c++},u,a=()=>{f(),l(),u=setInterval(l,1e3*r)},f=()=>void 0!==u&&clearInterval(u),i=()=>f(),s=()=>{c=0,o=0,f()};return{setDuration:_,start:a,stop:s,pause:i,duration:$,interval:r,getPhase:()=>o,minLatency:.01}}function getClock(e,t,$){let r=createClock(()=>e.currentTime,t,$);return r}function bufferclock(e,t){let $,r=.1*e.sampleRate,n=e.createBuffer(1,r,e.sampleRate),c=new Float32Array(r),o=getClock(e,()=>{for(let r=0;r<c.length;r++)c[r]=t(r);n.getChannelData(0).set(c);let o=e.createBufferSource();o.buffer=n,o.connect(e.destination),($=$||e.currentTime)<e.currentTime&&console.log("OH NO..."),o.start($),$+=o.buffer.duration,o.stop($)},.1);return o}document.addEventListener("click",function e(){let t=new AudioContext,$=0,r=bufferclock(t,()=>((++$*(15&"36364689"[$>>13&7])/12&128)+((($>>12^($>>12)-2)%11*$/4|$>>13)&127)&255)/127.5-1);r.start(),setTimeout(()=>{r.stop()},1e4),document.removeEventListener("click",e)});

unminified:

function createClock(
  getTime,
  callback, // called slightly before each cycle
  duration = 0.05, // duration of each cycle
  interval = 0.1, // interval between callbacks
  overlap = 0.1 // overlap between callbacks
) {
  let tick = 0; // counts callbacks
  let phase = 0; // next callback time
  let precision = 10 ** 4; // used to round phase
  let minLatency = 0.01;
  const setDuration = (setter) => (duration = setter(duration));
  overlap = overlap || interval / 2;
  const onTick = () => {
    const t = getTime();
    const lookahead = t + interval + overlap; // the time window for this tick
    if (phase === 0) {
      phase = t + minLatency;
    }
    // callback as long as we're inside the lookahead
    while (phase < lookahead) {
      phase = Math.round(phase * precision) / precision;
      phase >= t && callback(phase, duration, tick);
      phase < t && console.log("TOO LATE", phase); // what if latency is added from outside?
      phase += duration; // increment phase by duration
      tick++;
    }
  };
  let intervalID;
  const start = () => {
    clear(); // just in case start was called more than once
    onTick();
    intervalID = setInterval(onTick, interval * 1000);
  };
  const clear = () => intervalID !== undefined && clearInterval(intervalID);
  const pause = () => clear();
  const stop = () => {
    tick = 0;
    phase = 0;
    clear();
  };
  const getPhase = () => phase;
  // setCallback
  return {
    setDuration,
    start,
    stop,
    pause,
    duration,
    interval,
    getPhase,
    minLatency,
  };
}

// clock specific for AudioContext
function getClock(ac, fn, interval) {
  const getTime = () => ac.currentTime;
  const clock = createClock(
    getTime,
    // called slightly before each cycle
    fn,
    interval // duration of each cycle
  );
  return clock;
}

function bufferclock(ac, fn) {
  let t = 0,
    playhead;
  const interval = 0.1;
  const nSamples = interval * ac.sampleRate;
  const buffer = ac.createBuffer(1, nSamples, ac.sampleRate);
  const samples = new Float32Array(nSamples);

  const clock = getClock(
    ac,
    () => {
      // fill buffer
      for (let i = 0; i < samples.length; i++) {
        samples[i] = fn(i); // call fn on each sample
      }
      buffer.getChannelData(0).set(samples);

      // play buffer
      const source = ac.createBufferSource();
      source.buffer = buffer;
      source.connect(ac.destination);
      playhead = playhead || ac.currentTime;
      playhead < ac.currentTime && console.log("OH NO...");
      source.start(playhead);
      playhead += source.buffer.duration;
      source.stop(playhead);
    },
    interval
  );
  return clock;
}

document.addEventListener("click", function handleClick() {
  const ac = new AudioContext();
  let t = 0;
  const clock = bufferclock(ac, () => {
    t++;
    return (
      // ryg 2011-10-10
      (((((t * ("36364689"[(t >> 13) & 7] & 15)) / 12) & 128) +
        (((((((t >> 12) ^ ((t >> 12) - 2)) % 11) * t) / 4) | (t >> 13)) &
          127)) &
        255) /
        127.5 -
      1
    );
  });

  clock.start();
  setTimeout(() => {
    clock.stop();
  }, 10000);
  document.removeEventListener("click", handleClick);
});

❤️ 2024 Felix Roos | mastodon | github