sciptprocessor increasing CPU usage in Angular 10 with changes to input stream. Also how to destroy it

225 Views Asked by At

Looking to free up CPU resources from a volume meter I have implemented on a device-access component route. This volume-meter component takes an existing stream set up in a parent component (device-access), using a WebRTC navigator.mediaDevices.getUserMedia call. The parent component permits the user to switch audio input devices and have the volume meter reflect the input's volume/gain/feedback reflected in the volume meter.

There is currently an issue where on multiple switches of devices, the CPU level gradually goes upwards. Additionally, this device-access page is a gateway to a video meeting component. A common scenario will have the user going back to this deice-access page. When the user goes back , the onaudioprocess is still running and compounds the CPU usage.

Following is the code. I have implemented an ngOnDestroy in the child component (volume-meter), but it does not seem to be impacting the process still running. When i switch the input audio device (in the subscribe), I want to kill the scriptprocessor and restart it. How do I do this?

export class VolumeMeterComponent implements OnInit, AfterViewInit, OnDestroy {

  private stream: MediaStream = null;
  private audioContext: AudioContext = null;
  private meter: any = null;
  private canvasContext: any = null;
  // height and width of the volume meter
  private WIDTH: number = 146;
  private HEIGHT: number = 9;
  private rafID: number = null;
  private mediaStreamSource: any = null;
  private clipping: boolean = null;
  private lastClip: number = null;
  private volume:number = null;
  // averaging: how "smoothed" you would like the meter to be over time.  
  // Should be between 0 and less than 1.
  private averaging: number = .95;
  // the level (0 to 1) that you would consider "clipping"
  private clipLevel: number = .98;
  // clipLag: how long you would like the "clipping" indicator to show after clipping has occurred, in milliseconds.
  private clipLag: number = 750;
  private loopInstance: any = null;
  private processHandle: any = null;
  // @ts-ignore
  @ViewChild('meterElement', {read: ElementRef, static: false}) meterElement: ElementRef;

  constructor(private streamService: StreamService) {}

  ngOnInit(): void {
    // nothing here for now
  }

  ngAfterViewInit(): void {
    this.streamService.stream$.subscribe(stream =>{
      this.stream = stream;
      if (this.loopInstance) {
        this.processHandle.stop();
        this.cleanupMeterOnChange();
      }
      this.initializeMeter();
    });
  }

  ngOnDestroy(): void {
    this.cleanupMeterOnChange();
  }

  cleanupMeterOnChange():void {
    // cleanup the canvasContext and meter onDestroy
    // best practice is to cleanup the the canvasContext as it has processes
    this.meter = null;
    this.canvasContext = null;
    this.drawLoop = null;
  }

  initializeMeter():void {
    this.canvasContext = this.meterElement.nativeElement.getContext('2d');
    // update audioContext to whatever is available from browser
    try {
      (window as any).AudioContext = (window as any).AudioContext || (window as any).webkitAudioContext;
      this.audioContext = new AudioContext();
      this.mediaStreamSource = this.audioContext.createMediaStreamSource(this.stream);
      // Create a new volume meter and connect it.
      this.meter = this.createAudioMeter(this.audioContext);
      this.mediaStreamSource.connect(this.meter);
      this.loopInstance = this.drawLoop();
    } catch(error) {
      console.log('Error setting up the volume meter. ' + error);
    }
  }

  drawLoop = () => {
    // clear the background
    this.canvasContext.clearRect(0, 0, this.WIDTH, this.HEIGHT);
    // check if we're currently clipping
    if (this.meter.checkClipping()) {
      this.canvasContext.fillStyle = 'red';
    } else {
      this.canvasContext.fillStyle = 'green';
    }
    // draw a bar based on the current volume
    this.canvasContext.fillRect(0, 0, this.meter.volume * this.WIDTH * 1.4, this.HEIGHT);

    // set up the next visual callback
    this.rafID = window.requestAnimationFrame( this.drawLoop );
  }

  createAudioMeter = audioContext => {
    const processor = audioContext.createScriptProcessor(2048, 1, 1);
    processor.onaudioprocess = this.volumeAudioProcess;
    this.processHandle = processor;
    processor.clipping = false;
    processor.lastClip = 0;
    processor.volume = 0;
    processor.clipLevel = this.clipLevel;
    processor.averaging = this.averaging;
    processor.clipLag = this.clipLag;

    // this will have no effect, since we don't copy the input to the output,
    // but works around a current Chrome bug.
    processor.connect(audioContext.destination);
    processor.checkClipping =
      checkClipping;

    // tslint:disable-next-line:typedef
    function checkClipping() {
      const that = this;
      if (!that.clipping) {
        return false;
      }

      if ((that.lastClip + that.clipLag) < window.performance.now()) {
        that.clipping = false;
      }
      return that.clipping;
    }

    processor.shutdown =
      function(): void {
        this.disconnect();
        this.onaudioprocess = null;
      };

    return processor;
  }

  volumeAudioProcess( event ): void {
    this.clipping = false;
    const buf = event.inputBuffer.getChannelData(0);
    const bufLength = buf.length;
    let sum = 0;
    let x;

    // Do a root-mean-square on the samples: sum up the squares...
    for (let i = 0; i < bufLength; i++) {
      x = buf[i];
      if (Math.abs(x) >= this.clipLevel) {
        this.clipping = true;
        this.lastClip = window.performance.now();
      }
      sum += x * x;
    }

    // ... then take the square root of the sum.
    const rms =  Math.sqrt(sum / bufLength);

    // Now smooth this out with the averaging factor applied
    // to the previous sample - take the max here because we
    // want "fast attack, slow release."
    this.volume = Math.max(rms, this.volume * this.averaging);
  }
}

The components markup:

<canvas id="meterElement" #meterElement width="146" height="8"></canvas>
<p class="level-label">Microphone volume level</p>

I have tried subscribing to the canvas using a ViewChild and unsubscribing, but have not had much luck. Anyone have any insight into a strategy to run this more efficiently. Would subscribing to drawLoop (and extracting it to a service), be the best answer?

I know WebRTC recommends audioWorklets: https://alvestrand.github.io/audio-worklet/

  • this is a draft and does not have Safari adoption. It does seem to be a better solution long term.
1

There are 1 best solutions below

1
On BEST ANSWER

You can call close() on an AudioContext to stop all its nodes and to make it release any system resources that it needs to use to do its thing. I think adding this to your cleanupMeterOnChange() method should improve the performance.

cleanupMeterOnChange():void {
    // ...
    this.audioContext.close();
}