Mercurial > hg > ugly-duckling
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"); + } + } +}