view src/app/services/audio-recorder/audio-recorder.service.ts @ 452:b4372cdf495c

Firefox doesn't seem to populate the MIME type field of MediaRecorder. Do some naive deducing of MIME type based on testing support for some known types and constructing MediaRecorder with the first one that passes. Fall back to implementation default of none of the types are supported.
author Lucas Thompson <dev@lucas.im>
date Thu, 29 Jun 2017 14:34:16 +0100
parents 72d9303a1513
children f52eb1b422f5
line wrap: on
line source
/**
 * Created by lucas on 17/03/2017.
 */
import {Injectable, Inject, NgZone} from '@angular/core';
import {Observable} from 'rxjs/Observable';
import {Subject} from 'rxjs/Subject';


// seems the TypeScript definitions are not up to date,
// introduce own types for now

export type AudioInputProvider = () => Promise<MediaStream>;

export interface MediaRecorderOptions {
  mimeType?: string;
  audioBitsPerSecond?: number;
  videoBitsPerSecond?: number;
  bitsPerSecond?: number;
}

export type RecordingState = 'inactive' | 'recording' | 'paused';

export interface BlobEvent extends Event {
  readonly data: Blob;
  readonly timecode?: number;
}

export interface MediaRecorderErrorEvent extends Event {
  readonly error: DOMException;
}

export interface MediaRecorder {
  readonly mimeType: string;
  readonly state: RecordingState;
  readonly stream: MediaStream;
  ignoreMutedMedia: boolean;
  readonly videoBitsPerSecond: number;
  readonly audioBitsPerSecond: number;
  // isTypeSupported(mimeType: string): boolean;
  onstart: (evt: Event) => void;
  onstop: (evt: Event) => void;
  ondataavailable: (evt: BlobEvent) => void;
  onpause: (evt: Event) => void;
  onresume: (evt: Event) => void;
  onerror: (evt: MediaRecorderErrorEvent) => void;
  pause(): void;
  requestData(): void;
  resume(): void;
  start(timeslice?: number): void;
  stop(): void;
}

export interface MediaRecorderConstructor {
  new(stream: MediaStream,
      options?: MediaRecorderOptions): MediaRecorder;
  isTypeSupported(mimeType: string): boolean;
}

export type RecorderServiceStatus = 'disabled' | 'enabled' | 'recording';

export class ThrowingMediaRecorder implements MediaRecorder {
  mimeType: string;
  state: RecordingState;
  stream: MediaStream;
  ignoreMutedMedia: boolean;
  videoBitsPerSecond: number;
  audioBitsPerSecond: number;
  onstart: (evt: Event) => void;
  onstop: (evt: Event) => void;
  ondataavailable: (evt: BlobEvent) => void;
  onpause: (evt: Event) => void;
  onresume: (evt: Event) => void;
  onerror: (evt: MediaRecorderErrorEvent) => void;

  static isTypeSupported(mimeType: string): boolean {
    return false;
  }

  constructor(stream: MediaStream,
              options?: MediaRecorderOptions) {
    throw new Error('MediaRecorder not available in this browser.');
  }


  pause(): void {
  }

  requestData(): void {
  }

  resume(): void {
  }

  start(timeslice: number): void {
  }

  stop(): void {
  }
}

@Injectable()
export class AudioRecorderService {
  private requestProvider: AudioInputProvider;
  private recorderImpl: MediaRecorderConstructor;
  private currentRecorder: MediaRecorder;
  private recordingStateChange: Subject<RecorderServiceStatus>;
  recordingStateChange$: Observable<RecorderServiceStatus>;
  private newRecording: Subject<Blob>;
  newRecording$: Observable<Blob>;
  private isRecording: boolean;
  private chunks: Blob[];
  private knownTypes = [
    {mimeType: 'audio/ogg', extension: 'ogg'},
    {mimeType: 'audio/webm', extension: 'webm'},
    {mimeType: 'audio/wav', extension: 'wav'}
  ];

  constructor(@Inject('AudioInputProvider') requestProvider: AudioInputProvider,
              @Inject(
                'MediaRecorderFactory'
              ) recorderImpl: MediaRecorderConstructor,
              private ngZone: NgZone) {
    this.requestProvider = requestProvider;
    this.recorderImpl = recorderImpl;
    this.recordingStateChange = new Subject<RecorderServiceStatus>();
    this.recordingStateChange$ = this.recordingStateChange.asObservable();
    this.newRecording = new Subject<Blob>();
    this.newRecording$ = this.newRecording.asObservable();
    this.isRecording = false;
    this.chunks = [];
  }

  private getRecorderInstance(): Promise<MediaRecorder> {
    return this.requestProvider().then(stream => {
      const supported = this.knownTypes.find(
        ({mimeType, extension}) => this.recorderImpl.isTypeSupported(mimeType)
      );
      const recorder = new this.recorderImpl(stream, supported ? {
        mimeType: supported.mimeType
      } : {});

      recorder.ondataavailable = e => this.chunks.push(e.data);
      recorder.onstop = () => {
        const blob = new Blob(this.chunks, {
          'type': recorder.mimeType || supported.mimeType
        });
        this.chunks.length = 0;
        this.ngZone.run(() => {
          this.newRecording.next(
            blob
          );
        });
      };
      return recorder;
    });
  }

  toggleRecording(): void {
    if (this.isRecording) {
      this.endRecording();
    } else {
      this.getRecorderInstance()
        .then(recorder => this.startRecording(recorder))
        .catch(e => {
          this.recordingStateChange.next('disabled'); // don't really need to do this
          console.warn(e); // TODO emit an error message for display?
        });
    }
  }

  private startRecording(recorder: MediaRecorder): void {
    this.currentRecorder = recorder;
    this.isRecording = true;
    recorder.start();
    this.recordingStateChange.next('recording');
  }

  private endRecording(): void {
    if (this.currentRecorder) {
      this.isRecording = false;
      this.currentRecorder.stop();
      for (const track of this.currentRecorder.stream.getAudioTracks()) {
        track.stop();
      }
      this.chunks.length = 0; // empty the array
      this.recordingStateChange.next('enabled');
      this.currentRecorder = null;
    }
  }
}