changeset 148:81b83ed94831

Merge remote-tracking branch 'origin/feature/basic-recording'
author Chris Cannam <cannam@all-day-breakfast.com>
date Mon, 20 Mar 2017 14:12:33 +0000
parents 1497e8478734 (current diff) 262995cfd3e6 (diff)
children 38abcb4830f1
files
diffstat 6 files changed, 295 insertions(+), 8 deletions(-) [+]
line wrap: on
line diff
--- a/src/app/app.component.html	Mon Mar 20 12:07:41 2017 +0000
+++ b/src/app/app.component.html	Mon Mar 20 14:12:33 2017 +0000
@@ -4,9 +4,9 @@
   <span class="app-toolbar-filler"></span>
 
   <app-playback-control></app-playback-control>
-  <button md-icon-button>
-    <md-icon>mic_off</md-icon>
-  </button>
+  <ugly-recording-control
+    (finishedRecording)="onFileOpened($event)"
+  ></ugly-recording-control>
 
   <!-- This fills the remaining space of the current row -->
   <span class="app-toolbar-filler"></span>
--- a/src/app/app.component.ts	Mon Mar 20 12:07:41 2017 +0000
+++ b/src/app/app.component.ts	Mon Mar 20 14:12:33 2017 +0000
@@ -27,22 +27,27 @@
     );
   }
 
-  onFileOpened(file: File) {
+  onFileOpened(file: File | Blob) {
     this.canExtract = false;
     this.isProcessing = true;
     const reader: FileReader = new FileReader();
     const mimeType = file.type;
     reader.onload = (event: any) => {
-      this.audioService.loadAudioFromUrl(
-        URL.createObjectURL(new Blob([event.target.result], {type: mimeType}))
-      );
+      const url: string = file instanceof Blob ? URL.createObjectURL(file) :
+        URL.createObjectURL(new Blob([event.target.result], {type: mimeType}));
+      this.audioService.loadAudioFromUrl(url);
       // TODO use a rxjs/Subject instead?
-      this.audioService.decodeAudioData(event.target.result).then(audioBuffer => {
+      this.audioService.decodeAudioData(event.target.result)
+        .then(audioBuffer => {
         this.audioBuffer = audioBuffer;
         if (this.audioBuffer) {
           this.canExtract = true;
           this.isProcessing = false;
         }
+      }).catch(error => {
+        this.canExtract = false;
+        this.isProcessing = false;
+        console.warn(error);
       });
     };
     reader.readAsArrayBuffer(file);
--- a/src/app/app.module.ts	Mon Mar 20 12:07:41 2017 +0000
+++ b/src/app/app.module.ts	Mon Mar 20 14:12:33 2017 +0000
@@ -12,6 +12,15 @@
 import { FeatureExtractionService } from "./services/feature-extraction/feature-extraction.service";
 import { FeatureExtractionMenuComponent } from "./feature-extraction-menu/feature-extraction-menu.component";
 import { ProgressSpinnerComponent } from "./progress-spinner/progress-spinner.component";
+import {
+  AudioRecorderService,
+  AudioInputProvider,
+  MediaRecorderConstructor,
+  MediaRecorder as IMediaRecorder,
+  MediaRecorderOptions,
+  ThrowingMediaRecorder,
+} from "./services/audio-recorder/audio-recorder.service";
+import {RecordingControlComponent} from "./recording-control/recording-control.component";
 
 export function createAudioContext(): AudioContext {
   return new (
@@ -24,12 +33,39 @@
   return new Audio();
 }
 
+export function createAudioInputProvider(): AudioInputProvider {
+  if (navigator.mediaDevices &&
+    typeof navigator.mediaDevices.getUserMedia === 'function') {
+    return () => navigator.mediaDevices.getUserMedia(
+      {audio: true, video: false}
+    );
+  } else {
+    return () => Promise.reject('Recording is not supported in this browser.');
+  }
+}
+
+declare const MediaRecorder: {
+  prototype: IMediaRecorder;
+  new(stream: MediaStream,
+      options?: MediaRecorderOptions): IMediaRecorder;
+  isTypeSupported(mimeType: string): boolean;
+};
+
+export function createMediaRecorderFactory(): MediaRecorderConstructor {
+  if (typeof MediaRecorder !== 'undefined') {
+    return MediaRecorder;
+  } else {
+    return ThrowingMediaRecorder;
+  }
+}
+
 @NgModule({
   declarations: [
     AppComponent,
     WaveformComponent,
     AudioFileOpenComponent,
     PlaybackControlComponent,
+    RecordingControlComponent,
     FeatureExtractionMenuComponent,
     ProgressSpinnerComponent
   ],
@@ -43,7 +79,10 @@
     {provide: HTMLAudioElement, useFactory: createAudioElement}, // TODO use something more generic than HTMLAudioElement
     {provide: 'AudioContext', useFactory: createAudioContext}, // use a string token, Safari doesn't seem to like AudioContext
     AudioPlayerService,
+    {provide: 'AudioInputProvider', useFactory: createAudioInputProvider},
+    AudioRecorderService,
     FeatureExtractionService,
+    {provide: 'MediaRecorderFactory', useFactory: createMediaRecorderFactory},
     {provide: 'PiperRepoUri', useValue: 'assets/remote-plugins.json'}
   ],
   bootstrap: [AppComponent]
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/app/recording-control/recording-control.component.html	Mon Mar 20 14:12:33 2017 +0000
@@ -0,0 +1,8 @@
+<button md-icon-button (click)="emitToggleRecording()">
+  <md-icon>
+    <template [ngIf]="recordingStatus == 'enabled'">mic_none</template>
+    <template [ngIf]="recordingStatus == 'disabled'">mic_off</template>
+    <template [ngIf]="recordingStatus == 'recording'">mic_on</template>
+  </md-icon>
+</button>
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/app/recording-control/recording-control.component.ts	Mon Mar 20 14:12:33 2017 +0000
@@ -0,0 +1,54 @@
+/**
+ * Created by lucas on 17/03/2017.
+ */
+
+import {
+  Component,
+  OnInit,
+  OnDestroy,
+  Output,
+  EventEmitter
+} from "@angular/core";
+import {
+  AudioRecorderService,
+  RecorderServiceStatus
+} from "../services/audio-recorder/audio-recorder.service";
+import {Subscription} from "rxjs";
+
+@Component({
+  selector: 'ugly-recording-control',
+  templateUrl: './recording-control.component.html'
+})
+export class RecordingControlComponent implements OnInit, OnDestroy {
+  private recordingState: Subscription;
+  private newRecording: Subscription;
+  recordingStatus: RecorderServiceStatus;
+  @Output() finishedRecording: EventEmitter<Blob>;
+
+  constructor(private recordingService: AudioRecorderService) {
+    this.recordingStatus = "disabled";
+    this.finishedRecording = new EventEmitter<Blob>();
+  }
+
+  ngOnInit(): void {
+    this.recordingState = this.recordingService.recordingStateChange$.subscribe(
+      (status: RecorderServiceStatus) => {
+        this.recordingStatus = status;
+      }
+    );
+    this.newRecording = this.recordingService.newRecording$.subscribe(
+      (recordingBlob: Blob) => {
+        this.finishedRecording.emit(recordingBlob);
+      }
+    );
+  }
+
+  ngOnDestroy(): void {
+    this.recordingState.unsubscribe();
+    this.newRecording.unsubscribe();
+  }
+
+   emitToggleRecording() {
+     this.recordingService.toggleRecording();
+   }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/app/services/audio-recorder/audio-recorder.service.ts	Mon Mar 20 14:12:33 2017 +0000
@@ -0,0 +1,181 @@
+/**
+ * Created by lucas on 17/03/2017.
+ */
+import {Injectable, Inject} from "@angular/core";
+import {Subject, Observable} from "rxjs";
+
+
+// seems the TypeScript definitions are not up to date,
+// introduce own types for now
+
+export type AudioInputProvider = () => PromiseLike<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;
+  pause(): void;
+  requestData(): void;
+  resume(): void;
+  start(timeslice?: number): void;
+  stop(): void;
+  onstart: (evt: Event) => void;
+  onstop: (evt: Event) => void;
+  ondataavailable: (evt: BlobEvent) => void;
+  onpause: (evt: Event) => void;
+  onresume: (evt: Event) => void;
+  onerror: (evt: MediaRecorderErrorEvent) => 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;
+
+  constructor(stream: MediaStream,
+              options?: MediaRecorderOptions) {
+    throw "MediaRecorder not available in this browser."
+  }
+
+  static isTypeSupported(mimeType: string): boolean {
+    return false;
+  }
+
+  pause(): void {
+  }
+
+  requestData(): void {
+  }
+
+  resume(): void {
+  }
+
+  start(timeslice: number): void {
+  }
+
+  stop(): void {
+  }
+}
+
+@Injectable()
+export class AudioRecorderService {
+  private requestProvider: AudioInputProvider;
+  private recorderImpl: MediaRecorderConstructor;
+  private recorder: MediaRecorder;
+  private recordingStateChange: Subject<RecorderServiceStatus>;
+  recordingStateChange$: Observable<RecorderServiceStatus>;
+  private newRecording: Subject<Blob>;
+  newRecording$: Observable<Blob>;
+  private isRecordingAble: boolean;
+  private isRecording: boolean;
+  private chunks: Blob[];
+
+  constructor(@Inject('AudioInputProvider') requestProvider: AudioInputProvider,
+              @Inject(
+                'MediaRecorderFactory'
+              ) recorderImpl: MediaRecorderConstructor) {
+    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.isRecordingAble = false;
+    this.isRecording = false;
+    this.chunks = [];
+    this.hasRecordingCapabilities();
+  }
+
+  private hasRecordingCapabilities(): void {
+    this.requestProvider().then(stream => {
+      try {
+        this.recorder = new this.recorderImpl(stream);
+
+        this.recorder.ondataavailable = e => this.chunks.push(e.data);
+        this.recorder.onstop = () => {
+          const blob = new Blob(this.chunks, { 'type': this.recorder.mimeType });
+          this.chunks.length = 0;
+          this.newRecording.next(
+            blob
+          );
+        };
+        this.isRecordingAble = true;
+        this.recordingStateChange.next("enabled");
+      } catch (e) {
+        this.isRecordingAble = false;
+        this.recordingStateChange.next("disabled"); // don't really need to do this
+        console.warn(e); // TODO emit an error message for display?
+      }
+    }, rejectMessage => {
+      this.isRecordingAble = false;
+      this.recordingStateChange.next("disabled"); // again, probably not needed
+      console.warn(rejectMessage); // TODO something better
+    });
+  }
+
+  toggleRecording(): void {
+    if (!this.isRecordingAble) return;
+
+    if (this.isRecording) {
+      this.endRecording();
+    } else {
+      this.startRecording();
+    }
+  }
+
+  private startRecording(): void {
+    if (this.recorder) {
+      this.isRecording = true;
+      this.recorder.start();
+      this.recordingStateChange.next("recording");
+    }
+  }
+
+  private endRecording(): void {
+    if (this.recorder) {
+      this.isRecording = false;
+      this.recorder.stop();
+      this.chunks.length = 0; // empty the array
+      this.recordingStateChange.next("enabled");
+    }
+  }
+}