How do we schedule a series of oscillator nodes that play for a fixed duration with a smooth transition from one node’s ending to the other?

We are trying to map an array of numbers to sound and are following the approach mentioned in this ‘A Tale of 2 Clocks’ article to schedule oscillator nodes to play in the future. Each oscillator node exponentially ramps to a frequency value corresponding to the data in a fixed duration (this.pointSonificationLength). However, there’s a clicking noise as each node stops, as referenced in this article by Chris Wilson. The example here talks about smoothly stopping a single oscillator. However, we are unable to directly use this approach to smoothen the transition between one oscillator node to the other.

To clarify some of the values, pointTime refers to the node’s number in the order starting from 0, i.e. as the nodes were scheduled, they’d have pointTime = 0, 1, 2, and so forth. this.pointSonificationLength is the constant used to indicate how long the node should play for.

The first general approach was to decrease the gain at the end of the node so the change is almost imperceptible, as was documented in the article above. We tried implementing both methods, including setTargetAtTime and a combination of exponentialRampToValueAtTime and setValueAtTime, but neither worked to remove the click.

We were able to remove the click by changing some of our reasoning, and we scheduled for the gain to start transitioning to 0 at 100ms before the node ends.

However, when we scheduled more than one node, there was now a pause between each node. If we changed the function to start transitioning at 10ms, the gap was removed, but there was still a quiet click.

Our next approach was to have each node fade in as well as fade out. We added in this.delay as a constant to be the amount of time each transition in and out takes.

Below is where we’re currently at in the method to schedule an oscillator node for a given time with a given data point. The actual node scheduling is contained in another method inside the class.

private scheduleOscillatorNode(dataPoint: number, pointTime: number) {
    let osc = this.audioCtx.createOscillator()
    let amp = this.audioCtx.createGain()

    osc.frequency.value = this.previousFrequencyOfset
    osc.frequency.exponentialRampToValueAtTime(dataPoint, pointTime + this.pointSonificationLength)
    osc.onended = () => this.handleOnEnded()
    osc.connect(amp).connect(this.audioCtx.destination)

    let nodeStart = pointTime + this.delay * this.numNode;
    amp.gain.setValueAtTime(0.00001, nodeStart);
    amp.gain.exponentialRampToValueAtTime(1, nodeStart + this.delay);
    amp.gain.setValueAtTime(1, nodeStart + this.delay + this.pointSonificationLength);
    amp.gain.exponentialRampToValueAtTime(0.00001, nodeStart + this.delay * 2 + this.pointSonificationLength);

    osc.start(nodeStart)
    osc.stop(nodeStart + this.delay * 2 + this.pointSonificationLength)
    this.numNode++;

    this.audioQueue.enqueue(osc)
    // code to keep track of playback
}

We notice that there is a slight difference between the values we calculate manually and the values we see when we log the time values using the console.log statements, but the difference was too small to potentially be perceivable. As a result, we believe that this may not be causing the clicking noise, since the difference shouldn’t be perceivable if it’s so small. For example, instead of ending at 6.7 seconds, the node would end at 6.699999999999999 seconds, or if the node was meant to end at 5.6 seconds, it would actually end at 5.6000000000000005 seconds.

Is there a way to account for these delays and schedule nodes such that the transition occurs smoothly? Alternatively, is there a different approach that we need to use to make these transitions smooth? Any suggestions and pointers to code samples or other helpful resources would be of great help!