Mercurial > hg > ugly-duckling
changeset 475:5df3ce3574e5
Merge branch 'more-vertical-scales' of github.com:cannam/ugly-duckling into feature/undo-redo
author | Lucas Thompson <dev@lucas.im> |
---|---|
date | Fri, 30 Jun 2017 16:11:06 +0100 |
parents | 2142e7820706 (diff) f2b62195a5a6 (current diff) |
children | eacf505f7e1f |
files | src/app/analysis-item/analysis-item.component.html src/app/app.module.ts |
diffstat | 13 files changed, 490 insertions(+), 206 deletions(-) [+] |
line wrap: on
line diff
--- a/package.json Fri Jun 30 15:21:38 2017 +0100 +++ b/package.json Fri Jun 30 16:11:06 2017 +0100 @@ -5,6 +5,7 @@ "scripts": { "ng": "ng", "start": "ng serve", + "start-ssl": "ng serve --ssl true", "build": "node build-prod.js", "test": "ng test", "lint": "ng lint",
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/app/Session.ts Fri Jun 30 16:11:06 2017 +0100 @@ -0,0 +1,179 @@ +/** + * Created by lucast on 08/06/2017. + */ +import { + Item, + RootAudioItem +} from './analysis-item/AnalysisItem'; + +export const exampleSession: SerialisedNotebook = { + root: { + id: '1', + hasSharedTimeline: true, + title: 'Drum Loop', + description: 'Remotely hosted audio file', + uri: 'https://piper-audio.github.io/waves-ui-piper/examples/assets/drum-loop.wav' + }, + analyses: [ + { + id: '2', + hasSharedTimeline: true, + extractorKey: 'vamp-example-plugins:amplitudefollower', + outputId: 'amplitude', + title: 'Amplitude', + description: 'amplitude' + }, + { + id: '3', + hasSharedTimeline: true, + extractorKey: 'vamp-example-plugins:powerspectrum', + outputId: 'powerspectrum', + title: 'Simple Power Spectrum', + description: 'powerspectrum' + }, + + ] +}; + +export interface SerialisedAnalysisItem extends Item { + extractorKey: string; + outputId: string; +} + +export interface SerialisedNotebook { + root: RootAudioItem; + analyses: SerialisedAnalysisItem[]; +} + +export type ResourceRetriever = (url: string) => Promise<Blob>; + +export const downloadResource: ResourceRetriever = async (url) => { + const response = await fetch(url); + const mimeType = response.headers.get('content-type'); + // Safari's fetch.blob implementation doesn't populate the type property + // causing the audio player to fail due to an unsupported type. + // Manually create a blob from an array buffer and the content type in + // the response object + const arrayBufferToBlob = async () => { + const arrayBuffer = await response.arrayBuffer(); + return new Blob([arrayBuffer], {type: mimeType}); + }; + return mimeType ? arrayBufferToBlob() : response.blob(); +}; + +export class PersistentStack<T> { + private stack: T[]; + private history: T[][]; + private historyOffset: number; + + constructor() { + this.stack = []; + this.history = [[]]; + this.historyOffset = 0; + } + + shiftMutating(): T { + const item = this.stack[0]; + this.stack = this.stack.slice(1); + return item; + } + + shift(): T { + const item = this.shiftMutating(); + this.updateHistory(); + return item; + } + + unshiftMutating(item: T): number { + this.stack = [item, ...this.stack]; + return this.stack.length; + } + + unshift(item: T): number { + const newLength = this.unshift(item); + this.updateHistory(); + return newLength; + } + + findIndex(predicate: (value: T, + index: number, + array: T[]) => boolean): number { + return this.stack.findIndex(predicate); + } + + filter(predicate: (value: T, index: number, array: T[]) => boolean): T[] { + return this.stack.filter(predicate); + } + + get(index: number): T { + return this.stack[index]; + } + + set(index: number, value: T) { + this.setMutating(index, value); + this.updateHistory(); + } + + setMutating(index: number, value: T) { + this.stack = [ + ...this.stack.slice(0, index), + value, + ...this.stack.slice(index + 1) + ]; + } + + map<U>(transform: (value: T, index: number, array: T[]) => U): U[] { + return this.stack.map(transform); + } + + reduce<U>(reducer: (previousValue: U, + currentValue: T, + currentIndex: number, + array: T[]) => U, + initialValue: U): U { + return this.stack.reduce(reducer, initialValue); + } + + remove(...indices: number[]) { + this.stack = this.stack.reduce((acc, item, i) => { + if (!indices.includes(i)) { + acc.push(item); + } + return acc; + }, [] as T[]); + this.updateHistory(); + } + + stepBack(): void { + const latest = this.history.length - 1; + if (++this.historyOffset <= latest) { + this.stack = this.history[latest - this.historyOffset]; + } else { + this.historyOffset = latest; + } + } + + stepForward(): void { + const latest = this.history.length - 1; + if (--this.historyOffset >= 0) { + this.stack = this.history[latest - this.historyOffset]; + } else { + this.historyOffset = 0; + } + } + + toIterable(): Iterable<T> { + return this.stack; + } + + private updateHistory(): void { + if (this.historyOffset !== 0) { + this.history = this.history.slice( + 0, + this.history.length - this.historyOffset + ); + this.historyOffset = 0; + } + this.history.push([...this.stack]); + } +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/app/analysis-item/AnalysisItem.ts Fri Jun 30 16:11:06 2017 +0100 @@ -0,0 +1,86 @@ +/** + * Created by lucast on 08/06/2017. + */ +import {KnownShapedFeature} from '../visualisations/FeatureUtilities'; +import {SimpleRequest} from 'piper/HigherLevelUtilities'; +export abstract class Item { + id: string; + hasSharedTimeline: boolean; + title?: string; + description?: string; + progress?: number; +} + +export interface RootAudioItem extends Item { + uri: string; + mimeType?: string; + isExportable?: boolean; +} +export interface LoadedRootAudioItem extends RootAudioItem { + audioData: AudioBuffer; +} + +export interface AnalysisItem extends Item { + parent: LoadedRootAudioItem; + extractorKey: string; + outputId: string; +} + +export type ExtractedAnalysisItem = AnalysisItem & KnownShapedFeature & { + unit?: string +}; + +export function isItem(item: Item): item is Item { + return item.id != null && item.hasSharedTimeline != null; +} + +export function isPendingRootAudioItem(item: Item): item is RootAudioItem { + return isItem(item) && typeof (item as RootAudioItem).uri === 'string'; +} + +export function isLoadedRootAudioItem(item: Item): item is LoadedRootAudioItem { + return item && isPendingRootAudioItem(item) && + (item as LoadedRootAudioItem).audioData instanceof AudioBuffer; +} + +export function isPendingAnalysisItem(item: Item): item is AnalysisItem { + const downcast = (item as ExtractedAnalysisItem); + return isLoadedRootAudioItem(downcast.parent) + && typeof downcast.extractorKey === 'string'; +} + +export function isExtractedAnalysisItem(it: Item): it is ExtractedAnalysisItem { + const downcast = (it as ExtractedAnalysisItem); + return isPendingAnalysisItem(it) && + downcast.shape != null && + downcast.collected != null; +} + +export function getRootAudioItem(item: Item): RootAudioItem { + if (isPendingRootAudioItem(item)) { + return item; + } + if (isPendingAnalysisItem(item)) { + return item.parent; + } + throw new Error('Invalid item.'); +} + +// these should probably be actual concrete types with their own getUri methods +export function getRootUri(item: Item): string { + return getRootAudioItem(item).uri; +} + +export function createExtractionRequest(item: AnalysisItem): SimpleRequest { + return { + audioData: [...Array(item.parent.audioData.numberOfChannels).keys()] + .map(i => item.parent.audioData.getChannelData(i)), + audioFormat: { + sampleRate: item.parent.audioData.sampleRate, + channelCount: item.parent.audioData.numberOfChannels, + length: item.parent.audioData.length + }, + key: item.extractorKey, + outputId: item.outputId + }; +}
--- a/src/app/analysis-item/analysis-item.component.css Fri Jun 30 15:21:38 2017 +0100 +++ b/src/app/analysis-item/analysis-item.component.css Fri Jun 30 16:11:06 2017 +0100 @@ -15,6 +15,14 @@ margin-bottom: 8px; } +md-card-content { + margin-bottom: 0; +} + +md-card-actions { + text-align: right; +} + ugly-live-play-head { position: absolute; z-index: 99;
--- a/src/app/analysis-item/analysis-item.component.html Fri Jun 30 15:21:38 2017 +0100 +++ b/src/app/analysis-item/analysis-item.component.html Fri Jun 30 16:11:06 2017 +0100 @@ -105,4 +105,15 @@ </ng-template> </div> </md-card-content> + <md-card-actions + *ngIf="isAudioItem()"> + <a md-icon-button + *ngIf="isAudioItem() && item.isExportable" + [href]="sanitize(item.uri)" + [download]="generateFilename(item)" + ><md-icon>file_download</md-icon></a> + <button md-icon-button (click)="remove.emit(item)"> + <md-icon>delete_forever</md-icon> + </button> + </md-card-actions> </md-card>
--- a/src/app/analysis-item/analysis-item.component.ts Fri Jun 30 15:21:38 2017 +0100 +++ b/src/app/analysis-item/analysis-item.component.ts Fri Jun 30 16:11:06 2017 +0100 @@ -6,80 +6,29 @@ Component, Input, OnDestroy, - OnInit + OnInit, + Output, + EventEmitter } from '@angular/core'; import {naivePagingMapper} from '../visualisations/WavesJunk'; import {OnSeekHandler} from '../playhead/PlayHeadHelpers'; import { defaultColourGenerator, - HigherLevelFeatureShape, - KnownShapedFeature + HigherLevelFeatureShape } from '../visualisations/FeatureUtilities'; import { RenderLoopService, TaskRemover } from '../services/render-loop/render-loop.service'; - -export interface Item { - id: string; - hasSharedTimeline: boolean; - title?: string; - description?: string; - progress?: number; -} - -export interface PendingRootAudioItem extends Item { - uri: string; -} -export interface RootAudioItem extends PendingRootAudioItem { - audioData: AudioBuffer; -} - -export interface PendingAnalysisItem extends Item { - parent: RootAudioItem; - extractorKey: string; -} - -export type AnalysisItem = PendingAnalysisItem & KnownShapedFeature & { - unit?: string -}; - -export function isItem(item: Item): item is Item { - return item.id != null && item.hasSharedTimeline != null; -} - -export function isPendingRootAudioItem(item: Item): item is PendingRootAudioItem { - return isItem(item) && typeof (item as RootAudioItem).uri === 'string'; -} - -export function isRootAudioItem(item: Item): item is RootAudioItem { - return item && isPendingRootAudioItem(item) && - (item as RootAudioItem).audioData instanceof AudioBuffer; -} - -export function isPendingAnalysisItem(item: Item): item is AnalysisItem { - const downcast = (item as AnalysisItem); - return isRootAudioItem(downcast.parent) - && typeof downcast.extractorKey === 'string'; -} - -export function isAnalysisItem(item: Item): item is AnalysisItem { - const downcast = (item as AnalysisItem); - return isPendingAnalysisItem(item) && - downcast.shape != null && - downcast.collected != null; -} - -// these should probably be actual concrete types with their own getUri methods -export function getRootUri(item: Item): string { - if (isPendingRootAudioItem(item)) { - return item.uri; - } - if (isPendingAnalysisItem(item)) { - return item.parent.uri; - } - throw new Error('Invalid item: No URI property set.'); -} +import {DomSanitizer} from '@angular/platform-browser'; +import { + isExtractedAnalysisItem, + isLoadedRootAudioItem, + isPendingAnalysisItem, + isPendingRootAudioItem, + Item, + RootAudioItem +} from './AnalysisItem'; @Component({ selector: 'ugly-analysis-item', @@ -114,13 +63,17 @@ @Input() item: Item; @Input() contentWidth: number; @Input() onSeek: OnSeekHandler; + @Output() remove: EventEmitter<Item>; // TODO move / re-think - naivePagingMapper feels like a big ol' bodge private removeAnimation: TaskRemover; private hasProgressOnInit = false; private mIsActive: boolean; private mTimeline: Timeline; - constructor(private renderLoop: RenderLoopService) {} + constructor(private renderLoop: RenderLoopService, + private sanitizer: DomSanitizer) { + this.remove = new EventEmitter<Item>(); + } ngOnInit(): void { this.resetRemoveAnimation(); @@ -132,25 +85,25 @@ } isAudioItem(): boolean { - return this.item && isRootAudioItem(this.item); + return this.item && isLoadedRootAudioItem(this.item); } isPending(): boolean { return this.item && - !isRootAudioItem(this.item) && !isAnalysisItem(this.item) && + !isLoadedRootAudioItem(this.item) && !isExtractedAnalysisItem(this.item) && (isPendingAnalysisItem(this.item) || isPendingRootAudioItem(this.item)); } getFeatureShape(): HigherLevelFeatureShape | null { return !isPendingRootAudioItem(this.item) && - isAnalysisItem(this.item) ? this.item.shape : null; + isExtractedAnalysisItem(this.item) ? this.item.shape : null; } getDuration(): number | null { - if (isRootAudioItem(this.item)) { + if (isLoadedRootAudioItem(this.item)) { return this.item.audioData.duration; } - if (isAnalysisItem(this.item)) { + if (isExtractedAnalysisItem(this.item)) { return this.item.parent.audioData.duration; } } @@ -163,6 +116,18 @@ this.removeAnimation(); } + private sanitize(url: string) { + return this.sanitizer.bypassSecurityTrustUrl(url); + } + + private generateFilename(item: RootAudioItem): string { + // TODO this is too brittle, and will often produce the wrong result + // i.e. audio/mpeg results in .mpeg, when .mp3 is likely desired + const mimeParts = item.mimeType ? item.mimeType.split('/') : []; + const extension = mimeParts.length === 2 ? mimeParts[1] : ''; + return `${item.title}.${extension}`; + } + private resetRemoveAnimation(): void { if (this.removeAnimation) { this.removeAnimation();
--- a/src/app/app.component.html Fri Jun 30 15:21:38 2017 +0100 +++ b/src/app/app.component.html Fri Jun 30 16:11:06 2017 +0100 @@ -7,7 +7,7 @@ <ugly-playback-control></ugly-playback-control> <ugly-recording-control - (finishedRecording)="onFileOpened($event); tray.close()" + (finishedRecording)="onFileOpened($event, true); tray.close()" ></ugly-recording-control> <!-- This fills the remaining space of the current row --> @@ -17,10 +17,15 @@ <ugly-audio-file-open (fileOpened)="onFileOpened($event); tray.close()" ></ugly-audio-file-open> - <!-- menu opens when trigger button is clicked --> <button md-icon-button (click)="tray.toggle()"> <md-icon>extension</md-icon> </button> + <button md-icon-button (click)="analyses.stepBack()"> + <md-icon>undo</md-icon> + </button> + <button md-icon-button (click)="analyses.stepForward()"> + <md-icon>redo</md-icon> + </button> </md-toolbar> </div> @@ -33,8 +38,8 @@ </ugly-action-tray> <div class="ugly-content"> <ugly-notebook-feed + (removeItem)="removeItem($event)" [analyses]="analyses.toIterable()" - [rootAudioUri]="rootAudioItem.uri" [onSeek]="onSeek"></ugly-notebook-feed> </div> </div>
--- a/src/app/app.component.ts Fri Jun 30 15:21:38 2017 +0100 +++ b/src/app/app.component.ts Fri Jun 30 16:11:06 2017 +0100 @@ -1,69 +1,30 @@ -import {Component, OnDestroy} from '@angular/core'; +import {Component, Inject, OnDestroy} from '@angular/core'; import { AudioPlayerService, - AudioResourceError, AudioResource + AudioResourceError, + AudioResource } from './services/audio-player/audio-player.service'; -import {FeatureExtractionService} from './services/feature-extraction/feature-extraction.service'; +import { + ExtractionResult, + FeatureExtractionService +} from './services/feature-extraction/feature-extraction.service'; import {ExtractorOutputInfo} from './feature-extraction-menu/feature-extraction-menu.component'; import {DomSanitizer} from '@angular/platform-browser'; import {MdIconRegistry} from '@angular/material'; import {Subscription} from 'rxjs/Subscription'; import { AnalysisItem, - isRootAudioItem, - Item, PendingAnalysisItem, PendingRootAudioItem, RootAudioItem -} from './analysis-item/analysis-item.component'; + isPendingAnalysisItem, + isPendingRootAudioItem, + isLoadedRootAudioItem, + Item, + RootAudioItem, + getRootAudioItem +} from './analysis-item/AnalysisItem'; import {OnSeekHandler} from './playhead/PlayHeadHelpers'; - -class PersistentStack<T> { - private stack: T[]; - private history: T[][]; - - constructor() { - this.stack = []; - this.history = []; - } - - shift(): T { - this.history.push([...this.stack]); - const item = this.stack[0]; - this.stack = this.stack.slice(1); - return item; - } - - unshift(item: T): number { - this.history.push([...this.stack]); - this.stack = [item, ...this.stack]; - return this.stack.length; - } - - findIndex(predicate: (value: T, - index: number, - array: T[]) => boolean): number { - return this.stack.findIndex(predicate); - } - - filter(predicate: (value: T, index: number, array: T[]) => boolean): T[] { - return this.stack.filter(predicate); - } - - get(index: number): T { - return this.stack[index]; - } - - set(index: number, value: T) { - this.history.push([...this.stack]); - this.stack = [ - ...this.stack.slice(0, index), - value, - ...this.stack.slice(index + 1) - ]; - } - - toIterable(): Iterable<T> { - return this.stack; - } -} +import {UrlResourceLifetimeManager} from './app.module'; +import {createExtractionRequest} from './analysis-item/AnalysisItem'; +import {PersistentStack} from './Session'; @Component({ selector: 'ugly-root', @@ -71,26 +32,26 @@ styleUrls: ['./app.component.css'] }) export class AppComponent implements OnDestroy { - audioBuffer: AudioBuffer; // TODO consider revising canExtract: boolean; private onAudioDataSubscription: Subscription; private onProgressUpdated: Subscription; private analyses: PersistentStack<Item>; // TODO some immutable state container describing entire session private nRecordings: number; // TODO user control for naming a recording private countingId: number; // TODO improve uniquely identifying items - private rootAudioItem: RootAudioItem; private onSeek: OnSeekHandler; constructor(private audioService: AudioPlayerService, private featureService: FeatureExtractionService, private iconRegistry: MdIconRegistry, - private sanitizer: DomSanitizer) { + private sanitizer: DomSanitizer, + @Inject( + 'UrlResourceLifetimeManager' + ) private resourceManager: UrlResourceLifetimeManager) { this.analyses = new PersistentStack<AnalysisItem>(); this.canExtract = false; this.nRecordings = 0; this.countingId = 0; this.onSeek = (time) => this.audioService.seekTo(time); - this.rootAudioItem = {} as any; // TODO eugh iconRegistry.addSvgIcon( 'duck', @@ -104,12 +65,12 @@ this.analyses.shift(); this.canExtract = false; } else { - this.audioBuffer = (resource as AudioResource).samples; - this.rootAudioItem.audioData = this.audioBuffer; - if (this.audioBuffer) { + const audioData = (resource as AudioResource).samples; + if (audioData) { + const rootAudio = getRootAudioItem(this.analyses.get(0)); this.canExtract = true; const currentRootIndex = this.analyses.findIndex(val => { - return isRootAudioItem(val) && val.uri === this.rootAudioItem.uri; + return isPendingRootAudioItem(val) && val.uri === rootAudio.uri; }); if (currentRootIndex !== -1) { this.analyses.set( @@ -117,7 +78,7 @@ Object.assign( {}, this.analyses.get(currentRootIndex), - {audioData: this.audioBuffer} + {audioData} ) ); } @@ -132,7 +93,7 @@ return; } - this.analyses.set( + this.analyses.setMutating( index, Object.assign( {}, @@ -144,7 +105,7 @@ ); } - onFileOpened(file: File | Blob) { + onFileOpened(file: File | Blob, createExportableItem = false) { this.canExtract = false; const url = this.audioService.loadAudio(file); // TODO is it safe to assume it is a recording? @@ -164,44 +125,64 @@ hasSharedTimeline: true, title: title, description: new Date().toLocaleString(), - id: `${++this.countingId}` - } as PendingRootAudioItem; - this.rootAudioItem = pending as RootAudioItem; // TODO this is silly + id: `${++this.countingId}`, + mimeType: file.type, + isExportable: createExportableItem + } as RootAudioItem; // TODO re-ordering of items for display // , one alternative is a Angular Pipe / Filter for use in the Template - this.analyses.unshift(pending); + this.analyses.unshiftMutating(pending); } - extractFeatures(outputInfo: ExtractorOutputInfo): void { + extractFeatures(outputInfo: ExtractorOutputInfo): string { if (!this.canExtract || !outputInfo) { return; } this.canExtract = false; - const placeholderCard: PendingAnalysisItem = { - parent: this.rootAudioItem, - hasSharedTimeline: true, - extractorKey: outputInfo.combinedKey, - title: outputInfo.name, - description: outputInfo.outputId, - id: `${++this.countingId}`, - progress: 0 - }; - this.analyses.unshift(placeholderCard); + const rootAudio = getRootAudioItem(this.analyses.get(0)); - this.featureService.extract(`${this.countingId}`, { - audioData: [...Array(this.audioBuffer.numberOfChannels).keys()] - .map(i => this.audioBuffer.getChannelData(i)), - audioFormat: { - sampleRate: this.audioBuffer.sampleRate, - channelCount: this.audioBuffer.numberOfChannels, - length: this.audioBuffer.length - }, - key: outputInfo.extractorKey, - outputId: outputInfo.outputId - }).then(result => { // TODO subscribe to the extraction service instead + if (isLoadedRootAudioItem(rootAudio)) { + const placeholderCard: AnalysisItem = { + parent: rootAudio, + hasSharedTimeline: true, + extractorKey: outputInfo.extractorKey, + outputId: outputInfo.outputId, + title: outputInfo.name, + description: outputInfo.outputId, + id: `${++this.countingId}`, + progress: 0 + }; + this.analyses.unshiftMutating(placeholderCard); + this.sendExtractionRequest(placeholderCard); + return placeholderCard.id; + } + throw new Error('Cannot extract. No audio loaded'); + } + + removeItem(item: Item): void { + const indicesToRemove: number[] = this.analyses.reduce( + (toRemove, current, index) => { + if (isPendingAnalysisItem(current) && current.parent.id === item.id) { + toRemove.push(index); + } else if (item.id === current.id) { + toRemove.push(index); + } + return toRemove; + }, []); + this.analyses.remove(...indicesToRemove); + } + + ngOnDestroy(): void { + this.onAudioDataSubscription.unsubscribe(); + this.onProgressUpdated.unsubscribe(); + } + + private sendExtractionRequest(analysis: AnalysisItem): Promise<void> { + const findAndUpdateItem = (result: ExtractionResult): void => { + // TODO subscribe to the extraction service instead const i = this.analyses.findIndex(val => val.id === result.id); this.canExtract = true; if (i !== -1) { @@ -215,15 +196,15 @@ ) ); } // TODO else remove the item? - }).catch(err => { - this.canExtract = true; - this.analyses.shift(); - console.error(`Error whilst extracting: ${err}`); - }); - } - - ngOnDestroy(): void { - this.onAudioDataSubscription.unsubscribe(); - this.onProgressUpdated.unsubscribe(); + }; + return this.featureService.extract( + analysis.id, + createExtractionRequest(analysis)) + .then(findAndUpdateItem) + .catch(err => { + this.canExtract = true; + this.analyses.shift(); + console.error(`Error whilst extracting: ${err}`); + }); } }
--- a/src/app/app.module.ts Fri Jun 30 15:21:38 2017 +0100 +++ b/src/app/app.module.ts Fri Jun 30 16:11:06 2017 +0100 @@ -9,7 +9,6 @@ import { PlaybackControlComponent } from './playback-control/playback-control.component'; import { AudioPlayerService, - UrlResourceLifetimeManager, ResourceReader } from './services/audio-player/audio-player.service'; import { FeatureExtractionService } from './services/feature-extraction/feature-extraction.service'; @@ -93,6 +92,11 @@ }; } +export abstract class UrlResourceLifetimeManager { + abstract createUrlToResource(resource: File | Blob): string; + abstract revokeUrlToResource(url: string): void; +} + export function createResourceReader(): ResourceReader { return (resource) => { return new Promise((res, rej) => {
--- a/src/app/notebook-feed/notebook-feed.component.html Fri Jun 30 15:21:38 2017 +0100 +++ b/src/app/notebook-feed/notebook-feed.component.html Fri Jun 30 16:11:06 2017 +0100 @@ -1,7 +1,7 @@ <div class="feed"> <ng-template ngFor let-item [ngForOf]="analyses"> <div [class.break]="isAudioItem(item)"> - <ugly-analysis-item + <ugly-analysis-item (remove)="removeItem.emit($event)" [timeline]="getOrCreateTimeline(item)" [isActive]="isActiveItem(item)" [item]="item"
--- a/src/app/notebook-feed/notebook-feed.component.ts Fri Jun 30 15:21:38 2017 +0100 +++ b/src/app/notebook-feed/notebook-feed.component.ts Fri Jun 30 16:11:06 2017 +0100 @@ -4,21 +4,22 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, - Component, + Component, EventEmitter, Inject, Input, - OnDestroy + OnDestroy, Output } from '@angular/core'; import Waves from 'waves-ui-piper'; import { getRootUri, - isRootAudioItem, + isLoadedRootAudioItem, Item -} from '../analysis-item/analysis-item.component'; +} from '../analysis-item/AnalysisItem'; import {Observable} from 'rxjs/Observable'; import {Dimension} from '../app.module'; import {Subscription} from 'rxjs/Subscription'; import {OnSeekHandler} from '../playhead/PlayHeadHelpers'; +import {AudioPlayerService} from '../services/audio-player/audio-player.service'; @Component({ selector: 'ugly-notebook-feed', @@ -27,25 +28,44 @@ changeDetection: ChangeDetectionStrategy.OnPush }) export class NotebookFeedComponent implements OnDestroy { - @Input() analyses: Item[]; - @Input() set rootAudioUri(uri: string) { - this._rootAudioUri = uri; + @Input() set analyses(analyses: Item[]) { + const front = analyses[0]; + if (analyses !== this.mAnalyses) { + if (front && getRootUri(front) !== this.currentAudioUri) { + this.audioService.unload(); + this.audioService.loadAudioFromUri(getRootUri(front)); + } else if (!front) { + this.audioService.unload(); + } + } + this.mAnalyses = analyses; + if (front) { + this.currentAudioUri = this.getCurrentAudioUri(); + } else { + this.currentAudioUri = ''; + } } + + get analyses(): Item[] { + return this.mAnalyses; + } + @Input() onSeek: OnSeekHandler; + @Output() removeItem: EventEmitter<Item>; - get rootAudioUri(): string { - return this._rootAudioUri; - } - private _rootAudioUri: string; private resizeSubscription: Subscription; private width: number; private lastWidth: number; private timelines: Map<string, Timeline>; + private mAnalyses: Item[]; + private currentAudioUri: string; constructor( private ref: ChangeDetectorRef, - @Inject('DimensionObservable') private onResize: Observable<Dimension> + @Inject('DimensionObservable') private onResize: Observable<Dimension>, + private audioService: AudioPlayerService ) { + this.removeItem = new EventEmitter<Item>(); this.timelines = new Map(); this.onResize.subscribe(dim => { this.lastWidth = this.width; @@ -80,11 +100,11 @@ } isAudioItem(item: Item): boolean { - return isRootAudioItem(item); + return isLoadedRootAudioItem(item); } isActiveItem(item: Item): boolean { - return this.rootAudioUri === getRootUri(item); + return this.getCurrentAudioUri() === getRootUri(item); } getOnSeekForItem(item: Item): (timeSeconds: number) => any { @@ -96,4 +116,15 @@ this.resizeSubscription.unsubscribe(); } } + + private getCurrentAudioUri(): string { + if (this.analyses.length === 0) { + return ''; + } + try { + return getRootUri(this.analyses[0]); + } catch (e) { + return ''; + } + } }
--- a/src/app/services/audio-player/audio-player.service.ts Fri Jun 30 15:21:38 2017 +0100 +++ b/src/app/services/audio-player/audio-player.service.ts Fri Jun 30 16:11:06 2017 +0100 @@ -2,11 +2,7 @@ import {Subject} from 'rxjs/Subject'; import {Observable} from 'rxjs/Observable'; import {ReplaySubject} from 'rxjs/ReplaySubject'; - -export interface UrlResourceLifetimeManager { - createUrlToResource(resource: File | Blob): string; - revokeUrlToResource(url: string): void; -} +import {UrlResourceLifetimeManager} from '../../app.module'; export type ResourceReader = (resource: File | Blob) => Promise<ArrayBuffer>; @@ -64,16 +60,16 @@ return !this.audioElement.paused; } + loadAudioFromUri(uri: string): void { + this.currentObjectUrl = uri; + this.audioElement.pause(); + this.audioElement.src = uri; + this.audioElement.load(); + } loadAudio(resource: File | Blob): string { - if (this.currentObjectUrl) { - this.resourceManager.revokeUrlToResource(this.currentObjectUrl); - } const url: string = this.resourceManager.createUrlToResource(resource); - this.currentObjectUrl = url; - this.audioElement.pause(); - this.audioElement.src = url; - this.audioElement.load(); + this.loadAudioFromUri(url); const decode: (buffer: ArrayBuffer) => Promise<AudioBuffer> = buffer => { try { @@ -104,6 +100,10 @@ return url; } + unload(): void { + this.loadAudioFromUri(''); + } + togglePlaying(): void { if (this.audioElement.readyState >= 2) { this.isPlaying() ? this.audioElement.pause() : this.audioElement.play();
--- a/src/app/services/audio-recorder/audio-recorder.service.ts Fri Jun 30 15:21:38 2017 +0100 +++ b/src/app/services/audio-recorder/audio-recorder.service.ts Fri Jun 30 16:11:06 2017 +0100 @@ -109,6 +109,11 @@ 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( @@ -127,10 +132,18 @@ private getRecorderInstance(): Promise<MediaRecorder> { return this.requestProvider().then(stream => { - const recorder = new this.recorderImpl(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}); + const blob = new Blob(this.chunks, { + 'type': recorder.mimeType || supported.mimeType + }); this.chunks.length = 0; this.ngZone.run(() => { this.newRecording.next(