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(