view src/app/visualisations/FeatureUtilities.ts @ 368:a8a6e8a4ec70

Refactor the curve reshaping stuff to a utility function.
author Lucas Thompson <dev@lucas.im>
date Tue, 30 May 2017 22:15:42 +0100
parents bf038a51f7e3
children b77cd48d86a9
line wrap: on
line source
/**
 * Created by lucast on 24/05/2017.
 */

import {FeatureList} from 'piper/Feature';
import {OutputDescriptor, toSeconds} from 'piper';
import {
  MatrixFeature,
  SimpleResponse,
  TracksFeature,
  VectorFeature
} from 'piper/HigherLevelUtilities';


export type NoteLikeUnit = 'midi' | 'hz' ;
export interface Note {
  time: number;
  duration: number;
  pitch: number;
  velocity?: number;
}

export function getCanonicalNoteLikeUnit(unit: string): NoteLikeUnit | null {
  const canonicalUnits: NoteLikeUnit[] = ['midi', 'hz'];
  return canonicalUnits.find(canonicalUnit => {
    return unit.toLowerCase().indexOf(canonicalUnit) >= 0;
  });
}

export function mapFeaturesToNotes(featureData: FeatureList,
                                   descriptor: OutputDescriptor): Note[] {
  const canonicalUnit = getCanonicalNoteLikeUnit(descriptor.configured.unit);
  const isHz = canonicalUnit === 'hz';
  return featureData.map(feature => ({
    time: toSeconds(feature.timestamp),
    duration: toSeconds(feature.duration),
    pitch: isHz ?
      frequencyToMidiNote(feature.featureValues[0]) : feature.featureValues[0]
  }));
}

export function frequencyToMidiNote(frequency: number,
                                    concertA: number = 440.0): number {
  return 69 + 12 * Math.log2(frequency / concertA);
}

export function* createColourGenerator(colours) {
  let index = 0;
  const nColours = colours.length;
  while (true) {
    yield colours[index = ++index % nColours];
  }
}

export const defaultColourGenerator = createColourGenerator([
  '#0868ac', // "sapphire blue", our waveform / header colour
  '#c33c54', // "brick red"
  '#17bebb', // "tiffany blue"
  '#001021', // "rich black"
  '#fa8334', // "mango tango"
  '#034748' // "deep jungle green"
]);

// TODO this might belong somewhere else, or perhaps the stuff above ^^ does

export interface Instant {
  time: number;
  label: string;
}

type CollectedShape = 'vector' | 'matrix' | 'tracks';

// TODO regions
type ShapeDeducedFromList = 'instants' | 'notes';
export type HigherLevelFeatureShape = CollectedShape | ShapeDeducedFromList;

export type ShapedFeatureData = VectorFeature
  | MatrixFeature
  | TracksFeature
  | Note[]
  | Instant[];

// These needn't be classes (could just be interfaces), just experimenting
export abstract class ShapedFeature<Shape extends HigherLevelFeatureShape,
  Data extends ShapedFeatureData> {
  shape: Shape;
  collected: Data;
}

export class Vector extends ShapedFeature<'vector', VectorFeature> {}
export class Matrix extends ShapedFeature<'matrix', MatrixFeature> {}
export class Tracks extends ShapedFeature<'tracks', TracksFeature> {}
export class Notes extends ShapedFeature<'notes', Note[]> {}
export class Instants extends ShapedFeature<'instants', Instant[]> {}
export type KnownShapedFeature = Vector
  | Matrix
  | Tracks
  | Notes
  | Instants;

function hasKnownShapeOtherThanList(shape: string): shape is CollectedShape {
  return ['vector', 'matrix', 'tracks'].includes(shape);
}

const throwShapeError = () => { throw new Error('No shape could be deduced'); };
function deduceHigherLevelFeatureShape(response: SimpleResponse)
: HigherLevelFeatureShape {
  const collection = response.features;
  const descriptor = response.outputDescriptor;
  if (hasKnownShapeOtherThanList(collection.shape)) {
    return collection.shape;
  }


  // TODO it's a shame that the types in piper don't make this easy for the
  // compiler to deduce
  if (collection.shape !== 'list' && collection.collected instanceof Array) {
    throwShapeError();
  }

  const featureData = collection.collected as FeatureList;
  const hasDuration = descriptor.configured.hasDuration;
  const binCount = descriptor.configured.binCount;
  const isMarker = !hasDuration
    && binCount === 0
    && featureData[0].featureValues == null;

  const isMaybeNote = getCanonicalNoteLikeUnit(descriptor.configured.unit)
    && [1, 2].find(nBins => nBins === binCount);

  // TODO any need to be directly inspecting features?
  const isRegionLike = hasDuration && featureData[0].timestamp != null;

  const isNote = isMaybeNote && isRegionLike;
  if (isMarker) {
    return 'instants';
  }
  if (isNote) {
    return 'notes';
  }
  throwShapeError();
}

export function toKnownShape(response: SimpleResponse): KnownShapedFeature {
  const deducedShape = deduceHigherLevelFeatureShape(response);
  switch (deducedShape) {
    case 'vector':
      return response.features as Vector;
    case 'matrix':
      return response.features as Matrix;
    case 'notes':
      return {
        shape: deducedShape,
        collected: mapFeaturesToNotes(
          response.features.collected as FeatureList,
          response.outputDescriptor
        )
      };
    case 'instants':
      const featureData = response.features.collected as FeatureList;
      return {
        shape: deducedShape,
        collected: featureData.map(feature => ({
          time: toSeconds(feature.timestamp),
          label: feature.label
        }))
      };
  }
  throwShapeError();
}

export interface PlotData {
  cx: number;
  cy: number;
}

export interface PlotLayerData {
  data: PlotData[];
  yDomain: [number, number];
  startTime: number;
  duration: number;
}

export function generatePlotData(features: VectorFeature[]): PlotLayerData[] {

  const winnowed = features.filter(feature => feature.data.length > 0);

  // First establish a [min,max] range across all of the features
  let [min, max] = winnowed.reduce((acc, feature) => {
    return feature.data.reduce((acc, val) => {
      const [min, max] = acc;
      return [Math.min(min, val), Math.max(max, val)];
    }, acc);
  }, [Infinity, -Infinity]);

  if (min === Infinity) {
    min = 0;
    max = 1;
  }

  if (min !== min || max !== max) {
    console.warn('WARNING: min or max is NaN');
    min = 0;
    max = 1;
  }

  return winnowed.map(feature => {
    let duration = 0;

    // Give the plot items positions relative to the start of the
    // line, rather than relative to absolute time 0. This is
    // because we'll be setting the layer timeline start property
    // later on and these will be positioned relative to that

    const plotData = [...feature.data].map((val, i) => {
      const t = i * feature.stepDuration;
      duration = t + feature.stepDuration;
      return {
        cx: t,
        cy: val
      };
    });

    return {
      data: plotData,
      yDomain: [min, max] as [number, number],
      startTime: feature.startTime,
      duration: duration
    };
  });
}