Skip to content

Specify ScriptProcessNode behavior with a renderQuantumSize that doesn't divide its bufferSize #2677

Open

Description

@mjwilson-google

Currently the ScriptProcessorNode legal bufferSize values are all multiples of the default renderQuantumSize (128).

However, we now have configurable render quantum sizes which may not divide or be divided by the selected bufferSize.

It's not clear what implementations should do in this case. Should they add additional buffering internally, which will increase latency? Should they reject ScriptProcessorNodes created with bufferSizes that are not multiples of the renderQuantumSize? Or perhaps something else?

Specifying it precisely will help with interoperability between implementations.

Activity

  1. added
    Needs DiscussionThe issue needs more discussion before it can be fixed.
    category: enhancementSubstantive changes that do not add new features. https://www.w3.org/policies/process/#class-3
    Agenda+Should be discussed at the next WG meeting. https://speced.github.io/spec-maintenance/about/
    on Jun 11, 2026
  2. Korilakkuma commented on Jul 16, 2026

    @Korilakkuma
    Contributor

    ScriptProcessorNode is deprecated.
    Therefore, as one possible solution, use Overlap-Add enables audio signal processing in units of buffer sizes (FFT sizes) such as 2048.
    Please refer to OLAProcessor.

    // Filename is './audio-worklets/overlap-add.js'
    
    /**
     * This class extends `AudioWorkletProcessor`.
     */
    class OverlapAddProcessor extends AudioWorkletProcessor {
      static RENDER_QUANTUM_SIZE = 128;
    
      constructor(options) {
        super(options);
    
        this.frameSize = 2048;
        this.hopSize   = 128;
    
        this.numberOfOverlaps = this.frameSize / this.hopSize;
    
        this.inputBuffers       = [[]];  /** @type {[Float32Array[]]} */
        this.inputBuffersHead   = [[]];  /** @type {[Float32Array[]]} */
        this.inputBuffersToSend = [[]];  /** @type {[Float32Array[]]} */
    
        this.outputBuffers           = [[]];  /** @type {[Float32Array[]]} */
        this.outputBuffersToRetrieve = [[]];  /** @type {[Float32Array[]]} */
    
        if (options.processorOptions) {
          this.frameSize = options.processorOptions.frameSize ?? 2048;
        }
    
        this.allocateInputChannels(1);
        this.allocateOutputChannels(1);
      }
    
      /**
       * @param {[Float32Array[]]} inputs
       * @param {[Float32Array[]]} outputs
       * @param {{ [parameterName: string]: Float32Array }} parameters
       * @abstract
       */
      processOverlapAdd(inputs, outputs, parameters) {}
    
      /**
       * @param {[Float32Array[]]} inputs
       * @param {[Float32Array[]]} outputs
       * @param {{ [parameterName: string]: Float32Array }} parameters
       * @override
       */
      process(inputs, outputs, parameters) {
        this.reallocateChannelsIfNeeded(inputs, outputs);
    
        this.readInputs(inputs);
        this.shiftInputBuffers();
        this.prepareInputBuffersToSend();
        this.processOverlapAdd(this.inputBuffersToSend, this.outputBuffersToRetrieve, parameters);
        this.handleOutputBuffersToRetrieve();
        this.writeOutputs(outputs);
        this.shiftOutputBuffers();
    
        return true;
      }
    
      reallocateChannelsIfNeeded(inputs, outputs) {
        const inputNumberOfChannels = inputs[0].length;
    
        if (inputNumberOfChannels !== this.inputBuffers[0].length) {
          this.allocateInputChannels(inputNumberOfChannels);
        }
    
        const outputNumberOfChannels = outputs[0].length;
    
        if (outputNumberOfChannels !== this.outputBuffers[0].length) {
          this.allocateOutputChannels(outputNumberOfChannels);
        }
      }
    
      allocateInputChannels(numberOfChannels) {
        this.inputBuffers = [[]];
    
        for (let channelNumber = 0; channelNumber < numberOfChannels; channelNumber++) {
          this.inputBuffers[0][channelNumber] = new Float32Array(this.frameSize + OverlapAddProcessor.RENDER_QUANTUM_SIZE);
        }
    
        this.inputBuffersHead   = [[]];
        this.inputBuffersToSend = [[]];
    
        for (let channelNumber = 0; channelNumber < numberOfChannels; channelNumber++) {
          this.inputBuffersHead[0][channelNumber]   = this.inputBuffers[0][channelNumber].subarray(0, this.frameSize);
          this.inputBuffersToSend[0][channelNumber] = new Float32Array(this.frameSize);
        }
      }
    
      allocateOutputChannels(numberOfChannels) {
        this.outputBuffers = [[]];
    
        for (let channelNumber = 0; channelNumber < numberOfChannels; channelNumber++) {
          this.outputBuffers[0][channelNumber] = new Float32Array(this.frameSize);
        }
    
        this.outputBuffersToRetrieve = [[]];
    
        for (let channelNumber = 0; channelNumber < numberOfChannels; channelNumber++) {
          this.outputBuffersToRetrieve[0][channelNumber] = new Float32Array(this.frameSize);
        }
      }
    
      readInputs(inputs) {
        if (inputs[0].length && (inputs[0][0].length === 0)) {
          for (let channelNumber = 0; channelNumber < this.inputBuffers[0].length; channelNumber++) {
            this.inputBuffers[0][channelNumber].fill(0, this.frameSize);
          }
    
          return;
        }
    
        for (let channelNumber = 0; channelNumber < this.inputBuffers[0].length; channelNumber++) {
          this.inputBuffers[0][channelNumber].set(inputs[0][channelNumber], this.frameSize);
        }
      }
    
      writeOutputs(outputs) {
        for (let channelNumber = 0; channelNumber < this.inputBuffers[0].length; channelNumber++) {
          outputs[0][channelNumber].set(this.outputBuffers[0][channelNumber].subarray(0, OverlapAddProcessor.RENDER_QUANTUM_SIZE));
        }
      }
    
      shiftInputBuffers() {
        for (let channelNumber = 0; channelNumber < this.inputBuffers[0].length; channelNumber++) {
          this.inputBuffers[0][channelNumber].copyWithin(0, OverlapAddProcessor.RENDER_QUANTUM_SIZE);
        }
      }
    
      shiftOutputBuffers() {
        for (let channelNumber = 0; channelNumber < this.outputBuffers[0].length; channelNumber++) {
          this.outputBuffers[0][channelNumber].copyWithin(0, OverlapAddProcessor.RENDER_QUANTUM_SIZE);
          this.outputBuffers[0][channelNumber].subarray(this.frameSize - OverlapAddProcessor.RENDER_QUANTUM_SIZE).fill(0);
        }
      }
    
      prepareInputBuffersToSend() {
        for (let channelNumber = 0; channelNumber < this.inputBuffers[0].length; channelNumber++) {
          this.inputBuffersToSend[0][channelNumber].set(this.inputBuffersHead[0][channelNumber]);
        }
      }
    
      handleOutputBuffersToRetrieve() {
        for (let channelNumber = 0; channelNumber < this.outputBuffers[0].length; channelNumber++) {
          for (let n = 0; n < this.frameSize; n++) {
            this.outputBuffers[0][channelNumber][n] += this.outputBuffersToRetrieve[0][channelNumber][n] / this.numberOfOverlaps;
          }
        }
      }
    }

    For example, implement pitch shifter by using OverlapAddProcessor.

    class PitchShifterProcessor extends OverlapAddProcessor {
      static createHanningWindow(size) {
        const w = new Float32Array(size);
    
        for (let n = 0; n < size; n++) {
          w[n] = 0.5 - 0.5 * Math.cos((2 * Math.PI * n) / (size - 1));
        }
    
        return w;
      }
    
      constructor(options) {
        super(options);
    
        this.pitch = 1;
        this.speed = 1;
    
        this.timeCursor = 0;
    
        this.hanningWindow = PitchShifterProcessor.createHanningWindow(this.frameSize);
    
        this.port.onmessage = (event) => {
          if (event.data.pitch > 0) {
            this.pitch = event.data.pitch;
          }
    
          if (event.data.speed > 0) {
            this.speed = event.data.speed;
          }
        };
      }
    
      /** @overdrive */
      processOverlapAdd(inputs, outputs) {
        const input  = inputs[0];
        const output = outputs[0];
    
        const numberOfChannels = input.length;
    
        const fftSize = this.frameSize;
    
        for (let channelNumber = 0; channelNumber < numberOfChannels; channelNumber++) {
          const reals = new Float32Array(fftSize);
          const imags = new Float32Array(fftSize);
    
          for (let n = 0; n < fftSize; n++) {
            reals[n] = this.hanningWindow[n] * input[channelNumber][n];
          }
    
          FFT(reals, imags, fftSize);
    
          const halfOfFFTSizze = fftSize / 2;
          const bufferSize     = halfOfFFTSizze + 1;
    
          const magnitudes  = new Float32Array(bufferSize);
          const peakIndexes = new Uint16Array(bufferSize);
    
          for (let k = 0; k < bufferSize; k++) {
            magnitudes[k] = (reals[k] ** 2) + (imags[k] ** 2);
          }
    
          let numberOfPeaks = 0;
    
          // Find peaks
          let index = 2;
    
          const end = halfOfFFTSizze + 1 - 2;
    
          while (index < end) {
            const magnitude = magnitudes[index];
    
            if ((magnitudes[index - 1] >= magnitude) || (magnitudes[index - 2] >= magnitude)) {
              ++index;
              continue;
            }
    
            if ((magnitudes[index + 1] >= magnitude) || (magnitudes[index + 2] >= magnitude)) {
              ++index;
              continue;
            }
    
            peakIndexes[numberOfPeaks++] = index;
    
            index += 2;
          }
    
          // Shift peaks
          const shiftedReals = new Float32Array(fftSize);
          const shiftedImags = new Float32Array(fftSize);
    
          for (let k = 0; k < numberOfPeaks; k++) {
            const peakIndex = peakIndexes[k];
    
            const shiftedPeakIndex = Math.round(peakIndex * this.pitch * (1 / this.speed));
    
            if (shiftedPeakIndex > bufferSize) {
              break;
            }
    
            let startIndex = 0;
            let endIndex   = fftSize;;
    
            if (k > 0) {
              const peakIndexBefore = peakIndexes[k - 1];
    
              // Region of Influence
              startIndex = peakIndex - Math.floor((peakIndex - peakIndexBefore) / 2);
            }
    
            if (k < (numberOfPeaks - 1)) {
              const peakIndexAfter = peakIndexes[k + 1];
    
              // Region of Influence
              endIndex = peakIndex + Math.ceil((peakIndexAfter - peakIndex) / 2);
            }
    
            const startOffset = startIndex - peakIndex;
            const endOffset   = endIndex   - peakIndex;
    
            for (let m = startOffset; m < endOffset; m++) {
              const binCountIndex = peakIndex + m;
    
              const shiftedBinCountIndex = shiftedPeakIndex + m;
    
              if (shiftedBinCountIndex >= bufferSize) {
                break;
              }
    
              // Phase Correction
              const omega = (2 * Math.PI * (shiftedBinCountIndex - binCountIndex)) / fftSize;
    
              const shiftedReal = Math.cos(omega * this.timeCursor);
              const shiftedImag = Math.sin(omega * this.timeCursor);
    
              // Phase Unwrap
              shiftedReals[shiftedBinCountIndex] += (reals[binCountIndex] * shiftedReal) - (imags[binCountIndex] * shiftedImag);
              shiftedImags[shiftedBinCountIndex] += (reals[binCountIndex] * shiftedImag) + (imags[binCountIndex] * shiftedReal);
            }
          }
    
          // Amplitude Spectrum is line symmetry
          for (let k = 1; k < halfOfFFTSizze; k++) {
            shiftedReals[fftSize - k] = 0.0 + shiftedReals[k];
            shiftedImags[fftSize - k] = 0.0 - shiftedImags[k];
          }
    
          IFFT(shiftedReals, shiftedImags, fftSize);
    
          for (let n = 0; n < fftSize; n++) {
            output[channelNumber][n] = this.hanningWindow[n] * shiftedReals[n];
          }
        }
    
        this.timeCursor += this.hopSize;
      }
    }
    
    registerProcessor('PitchShifterProcessor', PitchShifterProcessor);
  3. mjwilson-google commented on Aug 3, 2026

    @mjwilson-google
    MemberAuthor

    Since discussion has not advanced on this issue Chromium has, as an interim fix, implemented throwing NotSupportedError for ScriptProcessorNode buffer sizes that are not a multiple of renderQuantumSize (https://crrev.com/c/7922729).

    This is not intended to circumvent the specification process. If the Audio Working Group comes to a different decision we will update our implementation.

  4. removed
    Agenda+Should be discussed at the next WG meeting. https://speced.github.io/spec-maintenance/about/
    on Aug 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Needs DiscussionThe issue needs more discussion before it can be fixed.category: enhancementSubstantive changes that do not add new features. https://www.w3.org/policies/process/#class-3

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions