changeset 460:ccce2c09502e

Manually cherry-pick various refactoring efforts from feature/basic-session-loading
author Lucas Thompson <dev@lucas.im>
date Fri, 30 Jun 2017 10:41:30 +0100
parents 8d561b6df2fa
children 34db9d45663f
files src/app/Session.ts src/app/analysis-item/AnalysisItem.ts src/app/analysis-item/analysis-item.component.ts src/app/app.component.ts src/app/notebook-feed/notebook-feed.component.ts
diffstat 5 files changed, 288 insertions(+), 196 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/app/Session.ts	Fri Jun 30 10:41:30 2017 +0100
@@ -0,0 +1,134 @@
+/**
+ * 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[][];
+
+  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)
+    ];
+  }
+
+  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.history.push([...this.stack]);
+    this.stack = this.stack.reduce((acc, item, i) => {
+      if (!indices.includes(i)) {
+        acc.push(item);
+      }
+      return acc;
+    }, [] as T[]);
+  }
+
+  toIterable(): Iterable<T> {
+    return this.stack;
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/app/analysis-item/AnalysisItem.ts	Fri Jun 30 10:41:30 2017 +0100
@@ -0,0 +1,82 @@
+/**
+ * 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;
+}
+
+// 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.');
+}
+
+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.ts	Fri Jun 30 00:59:38 2017 +0100
+++ b/src/app/analysis-item/analysis-item.component.ts	Fri Jun 30 10:41:30 2017 +0100
@@ -14,77 +14,21 @@
 import {OnSeekHandler} from '../playhead/PlayHeadHelpers';
 import {
   defaultColourGenerator,
-  HigherLevelFeatureShape,
-  KnownShapedFeature
+  HigherLevelFeatureShape
 } from '../visualisations/FeatureUtilities';
 import {
   RenderLoopService,
   TaskRemover
 } from '../services/render-loop/render-loop.service';
 import {DomSanitizer} from '@angular/platform-browser';
-
-export interface Item {
-  id: string;
-  hasSharedTimeline: boolean;
-  title?: string;
-  description?: string;
-  progress?: number;
-}
-
-export interface PendingRootAudioItem extends Item {
-  uri: string;
-  mimeType?: string;
-  isExportable?: boolean;
-}
-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 {
+  isExtractedAnalysisItem,
+  isLoadedRootAudioItem,
+  isPendingAnalysisItem,
+  isPendingRootAudioItem,
+  Item,
+  RootAudioItem
+} from './AnalysisItem';
 
 @Component({
   selector: 'ugly-analysis-item',
@@ -141,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;
     }
   }
@@ -176,7 +120,7 @@
     return this.sanitizer.bypassSecurityTrustUrl(url);
   }
 
-  private generateFilename(item: PendingRootAudioItem): string {
+  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('/') : [];
--- a/src/app/app.component.ts	Fri Jun 30 00:59:38 2017 +0100
+++ b/src/app/app.component.ts	Fri Jun 30 10:41:30 2017 +0100
@@ -4,94 +4,27 @@
   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,
-  isPendingAnalysisItem, isPendingRootAudioItem,
-  isRootAudioItem,
+  isPendingAnalysisItem,
+  isPendingRootAudioItem,
+  isLoadedRootAudioItem,
   Item,
-  PendingAnalysisItem,
-  PendingRootAudioItem,
-  RootAudioItem
-} from './analysis-item/analysis-item.component';
+  RootAudioItem,
+  LoadedRootAudioItem
+} from './analysis-item/AnalysisItem';
 import {OnSeekHandler} from './playhead/PlayHeadHelpers';
 import {UrlResourceLifetimeManager} from './app.module';
-
-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)
-    ];
-  }
-
-  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.history.push([...this.stack]);
-    this.stack = this.stack.reduce((acc, item, i) => {
-      if (!indices.includes(i)) {
-        acc.push(item);
-      }
-      return acc;
-    }, [] as T[]);
-  }
-
-  toIterable(): Iterable<T> {
-    return this.stack;
-  }
-}
+import {createExtractionRequest} from './analysis-item/AnalysisItem';
+import {PersistentStack} from './Session';
 
 @Component({
   selector: 'ugly-root',
@@ -105,7 +38,7 @@
   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 rootAudioItem: LoadedRootAudioItem;
   private onSeek: OnSeekHandler;
 
   constructor(private audioService: AudioPlayerService,
@@ -138,7 +71,7 @@
           if (this.rootAudioItem.audioData) {
             this.canExtract = true;
             const currentRootIndex = this.analyses.findIndex(val => {
-              return isRootAudioItem(val) && val.uri === this.rootAudioItem.uri;
+              return isLoadedRootAudioItem(val) && val.uri === this.rootAudioItem.uri;
             });
             if (currentRootIndex !== -1) {
               this.analyses.set(
@@ -196,63 +129,34 @@
       id: `${++this.countingId}`,
       mimeType: file.type,
       isExportable: createExportableItem
-    } as PendingRootAudioItem;
-    this.rootAudioItem = pending as RootAudioItem; // TODO this is silly
+    } as RootAudioItem;
+    this.rootAudioItem = pending as LoadedRootAudioItem; // TODO this is silly
 
     // TODO re-ordering of items for display
     // , one alternative is a Angular Pipe / Filter for use in the Template
     this.analyses.unshift(pending);
   }
 
-  extractFeatures(outputInfo: ExtractorOutputInfo): void {
+  extractFeatures(outputInfo: ExtractorOutputInfo): string {
     if (!this.canExtract || !outputInfo) {
       return;
     }
 
     this.canExtract = false;
 
-    const placeholderCard: PendingAnalysisItem = {
+    const placeholderCard: AnalysisItem = {
       parent: this.rootAudioItem,
       hasSharedTimeline: true,
-      extractorKey: outputInfo.combinedKey,
+      extractorKey: outputInfo.extractorKey,
+      outputId: outputInfo.outputId,
       title: outputInfo.name,
       description: outputInfo.outputId,
       id: `${++this.countingId}`,
       progress: 0
     };
     this.analyses.unshift(placeholderCard);
-
-    const audioBuffer = this.rootAudioItem.audioData;
-
-    this.featureService.extract(`${this.countingId}`, {
-      audioData: [...Array(audioBuffer.numberOfChannels).keys()]
-        .map(i => audioBuffer.getChannelData(i)),
-      audioFormat: {
-        sampleRate: audioBuffer.sampleRate,
-        channelCount: audioBuffer.numberOfChannels,
-        length: audioBuffer.length
-      },
-      key: outputInfo.extractorKey,
-      outputId: outputInfo.outputId
-    }).then(result => { // TODO subscribe to the extraction service instead
-      const i = this.analyses.findIndex(val => val.id === result.id);
-      this.canExtract = true;
-      if (i !== -1) {
-        this.analyses.set(
-          i,
-          Object.assign(
-            {},
-            this.analyses.get(i),
-            result.result,
-            result.unit ? {unit: result.unit} : {}
-          )
-        );
-      }  // TODO else remove the item?
-    }).catch(err => {
-      this.canExtract = true;
-      this.analyses.shift();
-      console.error(`Error whilst extracting: ${err}`);
-    });
+    this.sendExtractionRequest(placeholderCard);
+    return placeholderCard.id;
   }
 
   removeItem(item: Item): void {
@@ -270,13 +174,13 @@
       if (this.rootAudioItem.uri === item.uri) {
         this.audioService.unload();
         const topItem = this.analyses.get(0);
-        const nullRootAudio: RootAudioItem = {uri: ''} as any; // TODO eugh
+        const nullRootAudio: LoadedRootAudioItem = {uri: ''} as any; // TODO eugh
 
         if (topItem) {
           if (isPendingAnalysisItem(topItem)) {
-            this.rootAudioItem = topItem.parent as RootAudioItem;
-          } else if(isPendingRootAudioItem(topItem)) {
-            this.rootAudioItem = topItem as RootAudioItem
+            this.rootAudioItem = topItem.parent as LoadedRootAudioItem;
+          } else if (isPendingRootAudioItem(topItem)) {
+            this.rootAudioItem = topItem as LoadedRootAudioItem;
           } else {
            this.rootAudioItem = nullRootAudio;
           }
@@ -296,4 +200,32 @@
     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) {
+        this.analyses.set(
+          i,
+          Object.assign(
+            {},
+            this.analyses.get(i),
+            result.result,
+            result.unit ? {unit: result.unit} : {}
+          )
+        );
+      }  // TODO else remove the item?
+    };
+    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/notebook-feed/notebook-feed.component.ts	Fri Jun 30 00:59:38 2017 +0100
+++ b/src/app/notebook-feed/notebook-feed.component.ts	Fri Jun 30 10:41:30 2017 +0100
@@ -12,9 +12,9 @@
 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';
@@ -82,7 +82,7 @@
   }
 
   isAudioItem(item: Item): boolean {
-    return isRootAudioItem(item);
+    return isLoadedRootAudioItem(item);
   }
 
   isActiveItem(item: Item): boolean {