annotate src/app/waveform/waveform.component.ts @ 106:fe6b0c525a7d

Use matrix layer for matrix features
author Chris Cannam <cannam@all-day-breakfast.com>
date Thu, 09 Mar 2017 15:51:34 +0000
parents f8436dd53e7f
children 7740f7fd7c3c
rev   line source
dev@10 1 import {
dev@51 2 Component, OnInit, ViewChild, ElementRef, Input, AfterViewInit, NgZone,
dev@51 3 OnDestroy
dev@10 4 } from '@angular/core';
dev@39 5 import {AudioPlayerService} from "../services/audio-player/audio-player.service";
dev@36 6 import wavesUI from 'waves-ui';
dev@63 7 import {
dev@64 8 FeatureExtractionService
dev@63 9 } from "../services/feature-extraction/feature-extraction.service";
dev@51 10 import {Subscription} from "rxjs";
dev@63 11 import {
dev@63 12 FeatureCollection,
dev@64 13 FixedSpacedFeatures, SimpleResponse
dev@63 14 } from "piper/HigherLevelUtilities";
dev@53 15 import {toSeconds} from "piper";
dev@67 16 import {FeatureList, Feature} from "piper/Feature";
dev@81 17 import * as Hammer from 'hammerjs';
dev@8 18
dev@20 19 type Timeline = any; // TODO what type actually is it.. start a .d.ts for waves-ui?
dev@54 20 type Layer = any;
dev@54 21 type Track = any;
dev@59 22 type DisposableIndex = number;
dev@59 23 type Colour = string;
dev@6 24
dev@6 25 @Component({
dev@6 26 selector: 'app-waveform',
dev@6 27 templateUrl: './waveform.component.html',
dev@6 28 styleUrls: ['./waveform.component.css']
dev@6 29 })
dev@51 30 export class WaveformComponent implements OnInit, AfterViewInit, OnDestroy {
dev@20 31
dev@8 32 @ViewChild('track') trackDiv: ElementRef;
dev@6 33
dev@54 34 private _audioBuffer: AudioBuffer;
dev@54 35 private timeline: Timeline;
dev@54 36 private cursorLayer: any;
dev@54 37 private disposableLayers: Layer[];
dev@59 38 private colouredLayers: Map<DisposableIndex, Colour>;
dev@16 39
dev@16 40 @Input()
dev@16 41 set audioBuffer(buffer: AudioBuffer) {
dev@16 42 this._audioBuffer = buffer || undefined;
dev@20 43 if (this.audioBuffer)
dev@20 44 this.renderWaveform(this.audioBuffer);
dev@16 45 }
dev@16 46
dev@16 47 get audioBuffer(): AudioBuffer {
dev@16 48 return this._audioBuffer;
dev@16 49 }
dev@16 50
dev@51 51 private featureExtractionSubscription: Subscription;
dev@53 52 private playingStateSubscription: Subscription;
dev@53 53 private seekedSubscription: Subscription;
dev@53 54 private isPlaying: boolean;
dev@51 55
dev@31 56 constructor(private audioService: AudioPlayerService,
dev@51 57 private piperService: FeatureExtractionService,
dev@51 58 public ngZone: NgZone) {
dev@59 59 this.colouredLayers = new Map();
dev@54 60 this.disposableLayers = [];
dev@54 61 this._audioBuffer = undefined;
dev@54 62 this.timeline = undefined;
dev@54 63 this.cursorLayer = undefined;
dev@53 64 this.isPlaying = false;
dev@59 65 const colours = function* () {
dev@59 66 const circularColours = [
dev@59 67 'black',
dev@59 68 'red',
dev@59 69 'green',
dev@59 70 'purple',
dev@59 71 'orange'
dev@59 72 ];
dev@59 73 let index = 0;
dev@59 74 const nColours = circularColours.length;
dev@59 75 while (true) {
dev@59 76 yield circularColours[index = ++index % nColours];
dev@59 77 }
dev@59 78 }();
dev@59 79
dev@51 80 this.featureExtractionSubscription = piperService.featuresExtracted$.subscribe(
dev@51 81 features => {
dev@59 82 this.renderFeatures(features, colours.next().value);
dev@51 83 });
dev@53 84 this.playingStateSubscription = audioService.playingStateChange$.subscribe(
dev@53 85 isPlaying => {
dev@53 86 this.isPlaying = isPlaying;
dev@53 87 if (this.isPlaying)
dev@53 88 this.animate();
dev@53 89 });
dev@53 90 this.seekedSubscription = audioService.seeked$.subscribe(() => {
dev@53 91 if (!this.isPlaying)
dev@53 92 this.animate();
dev@53 93 });
dev@51 94 }
dev@51 95
dev@53 96 ngOnInit() {
dev@53 97 }
dev@10 98
dev@10 99 ngAfterViewInit(): void {
dev@51 100 this.timeline = this.renderTimeline();
dev@20 101 }
dev@20 102
dev@20 103 renderTimeline(duration: number = 1.0): Timeline {
dev@18 104 const track: HTMLElement = this.trackDiv.nativeElement;
dev@20 105 track.innerHTML = "";
dev@18 106 const height: number = track.getBoundingClientRect().height;
dev@18 107 const width: number = track.getBoundingClientRect().width;
dev@18 108 const pixelsPerSecond = width / duration;
dev@18 109 const timeline = new wavesUI.core.Timeline(pixelsPerSecond, width);
dev@18 110 timeline.createTrack(track, height, 'main');
dev@54 111 return timeline;
dev@54 112 }
dev@18 113
dev@54 114 renderWaveform(buffer: AudioBuffer): void {
dev@54 115 const height: number = this.trackDiv.nativeElement.getBoundingClientRect().height;
dev@54 116 const mainTrack = this.timeline.getTrackById('main');
dev@54 117 if (this.timeline) {
dev@54 118 // resize
dev@54 119 const width = this.trackDiv.nativeElement.getBoundingClientRect().width;
dev@55 120
dev@54 121 // loop through layers and remove them, waves-ui provides methods for this but it seems to not work properly
dev@55 122 const timeContextChildren = this.timeline.timeContext._children;
dev@55 123
dev@60 124 for (let i = 0, length = this.disposableLayers.length; i < length; ++i) {
dev@54 125 let layer = this.disposableLayers.pop();
dev@54 126 mainTrack.remove(layer);
dev@55 127
dev@55 128 const index = timeContextChildren.indexOf(layer.timeContext);
dev@55 129 if (index >= 0)
dev@55 130 timeContextChildren.splice(index, 1);
dev@54 131 layer.destroy();
dev@54 132 }
dev@59 133 this.colouredLayers.clear();
dev@59 134
dev@54 135 this.timeline.visibleWidth = width;
dev@54 136 this.timeline.pixelsPerSecond = width / buffer.duration;
dev@54 137 mainTrack.height = height;
dev@54 138 } else {
dev@54 139 this.timeline = this.renderTimeline(buffer.duration)
dev@54 140 }
dev@83 141 this.timeline.timeContext.offset = 0.5 * this.timeline.timeContext.visibleDuration;
cannam@106 142
dev@18 143 // time axis
dev@18 144 const timeAxis = new wavesUI.helpers.TimeAxisLayer({
dev@18 145 height: height,
cannam@106 146 color: '#b0b0b0'
dev@18 147 });
dev@54 148 this.addLayer(timeAxis, mainTrack, this.timeline.timeContext, true);
dev@18 149
dev@20 150 const waveformLayer = new wavesUI.helpers.WaveformLayer(buffer, {
dev@10 151 top: 10,
dev@20 152 height: height * 0.9,
dev@16 153 color: 'darkblue'
dev@16 154 });
dev@54 155 this.addLayer(waveformLayer, mainTrack, this.timeline.timeContext);
cannam@106 156 /*
cannam@106 157 const spectrogramLayer = new wavesUI.helpers.SpectrogramLayer(buffer, {
cannam@106 158 top: 10,
cannam@106 159 height: height * 0.9,
cannam@106 160 stepSize: 512,
cannam@106 161 fftSize: 1024
cannam@106 162 });
cannam@106 163 this.addLayer(spectrogramLayer, mainTrack, this.timeline.timeContext);
cannam@106 164 */
dev@53 165 this.cursorLayer = new wavesUI.helpers.CursorLayer({
dev@31 166 height: height
dev@31 167 });
dev@54 168 this.addLayer(this.cursorLayer, mainTrack, this.timeline.timeContext);
dev@51 169 this.timeline.state = new wavesUI.states.CenteredZoomState(this.timeline);
dev@54 170 mainTrack.render();
dev@54 171 mainTrack.update();
dev@81 172
dev@81 173
dev@81 174 if ('ontouchstart' in window) {
dev@84 175 const hammertime = new Hammer(this.trackDiv.nativeElement);
dev@81 176 const scroll = (ev) => {
dev@81 177 const sign = ev.direction === Hammer.DIRECTION_LEFT ? -1 : 1;
dev@81 178 let delta = this.timeline.timeContext.timeToPixel.invert(sign * ev.distance);
dev@96 179 const speed: number = Math.abs(ev.velocityX);
dev@96 180 delta *= (speed > 0.075 ? 0.075 : speed); // this is completely made up to limit the max speed, TODO something sensible
dev@81 181 this.timeline.timeContext.offset += delta;
dev@81 182 this.timeline.tracks.update();
dev@81 183 };
dev@84 184
dev@81 185 const zoom = (ev) => {
dev@81 186 const minZoom = this.timeline.state.minZoom;
dev@81 187 const maxZoom = this.timeline.state.maxZoom;
dev@81 188 const initialZoom = this.timeline.timeContext.zoom;
dev@81 189 const targetZoom = initialZoom * ev.scale;
dev@96 190 const lastCenterTime = this.timeline.timeContext.timeToPixel.invert(ev.center.x);
dev@81 191 this.timeline.timeContext.zoom = Math.min(Math.max(targetZoom, minZoom), maxZoom);
dev@96 192 const newCenterTime = this.timeline.timeContext.timeToPixel.invert(ev.center.x);
dev@96 193 this.timeline.timeContext.offset += newCenterTime - lastCenterTime;
dev@81 194 this.timeline.tracks.update();
dev@81 195 };
dev@84 196 const seek = (ev) => {
dev@84 197 this.audioService.seekTo(
dev@84 198 this.timeline.timeContext.timeToPixel.invert(ev.center.x) - this.timeline.timeContext.offset
dev@84 199 );
dev@84 200 };
dev@81 201 hammertime.get('pinch').set({ enable: true });
dev@81 202 hammertime.on('panleft', scroll);
dev@81 203 hammertime.on('panright', scroll);
dev@81 204 hammertime.on('pinch', zoom);
dev@84 205 hammertime.on('tap', seek);
dev@81 206 }
dev@81 207
dev@53 208 this.animate();
dev@53 209 }
dev@53 210
dev@53 211 // TODO refactor - this doesn't belong here
dev@64 212 private renderFeatures(extracted: SimpleResponse, colour: Colour): void {
dev@64 213 if (!extracted.hasOwnProperty('features') || !extracted.hasOwnProperty('outputDescriptor')) return;
dev@64 214 if (!extracted.features.hasOwnProperty('shape') || !extracted.features.hasOwnProperty('data')) return;
dev@64 215 const features: FeatureCollection = (extracted.features as FeatureCollection);
dev@64 216 const outputDescriptor = extracted.outputDescriptor;
dev@64 217 const height = this.trackDiv.nativeElement.getBoundingClientRect().height;
dev@64 218 const mainTrack = this.timeline.getTrackById('main');
dev@64 219
dev@64 220 // TODO refactor all of this
dev@63 221 switch (features.shape) {
dev@64 222 case 'vector': {
dev@63 223 const stepDuration = (features as FixedSpacedFeatures).stepDuration;
dev@63 224 const featureData = (features.data as Float32Array);
dev@68 225 if (featureData.length === 0) return;
dev@63 226 const normalisationFactor = 1.0 /
dev@63 227 featureData.reduce(
dev@63 228 (currentMax, feature) => Math.max(currentMax, feature),
dev@63 229 -Infinity
dev@63 230 );
dev@67 231
dev@63 232 const plotData = [...featureData].map((feature, i) => {
dev@63 233 return {
dev@63 234 cx: i * stepDuration,
dev@63 235 cy: feature * normalisationFactor
dev@63 236 };
dev@63 237 });
dev@67 238
dev@105 239 let lineLayer = new wavesUI.helpers.LineLayer(plotData, {
dev@63 240 color: colour,
dev@64 241 height: height
dev@63 242 });
dev@63 243 this.colouredLayers.set(this.addLayer(
dev@105 244 lineLayer,
dev@64 245 mainTrack,
dev@63 246 this.timeline.timeContext
dev@63 247 ), colour);
dev@63 248 break;
dev@64 249 }
dev@64 250 case 'list': {
dev@64 251 const featureData = (features.data as FeatureList);
dev@68 252 if (featureData.length === 0) return;
dev@64 253 // TODO look at output descriptor instead of directly inspecting features
dev@64 254 const hasDuration = outputDescriptor.configured.hasDuration;
dev@64 255 const isMarker = !hasDuration
dev@64 256 && outputDescriptor.configured.binCount === 0
dev@64 257 && featureData[0].featureValues == null;
dev@64 258 const isRegion = hasDuration
dev@64 259 && featureData[0].timestamp != null;
dev@64 260 // TODO refactor, this is incomprehensible
dev@64 261 if (isMarker) {
dev@64 262 const plotData = featureData.map(feature => {
dev@64 263 return {x: toSeconds(feature.timestamp)}
dev@64 264 });
dev@64 265 let markerLayer = new wavesUI.helpers.MarkerLayer(plotData, {
dev@64 266 height: height,
dev@64 267 color: colour,
dev@64 268 });
dev@64 269 this.colouredLayers.set(this.addLayer(
dev@64 270 markerLayer,
dev@64 271 mainTrack,
dev@64 272 this.timeline.timeContext
dev@64 273 ), colour);
dev@64 274 } else if (isRegion) {
dev@67 275 const binCount = outputDescriptor.configured.binCount || 0;
dev@67 276 const isBarRegion = featureData[0].featureValues.length >= 1 || binCount >= 1 ;
dev@64 277 const getSegmentArgs = () => {
dev@64 278 if (isBarRegion) {
dev@64 279
dev@67 280 // TODO refactor - this is messy
dev@67 281 interface FoldsToNumber<T> {
dev@67 282 reduce(fn: (previousValue: number,
dev@67 283 currentValue: T,
dev@67 284 currentIndex: number,
dev@67 285 array: ArrayLike<T>) => number,
dev@67 286 initialValue?: number): number;
dev@67 287 }
dev@64 288
dev@67 289 // TODO potentially change impl., i.e avoid reduce
dev@67 290 const findMin = <T>(arr: FoldsToNumber<T>, getElement: (x: T) => number): number => {
dev@67 291 return arr.reduce((min, val) => Math.min(min, getElement(val)), Infinity);
dev@67 292 };
dev@67 293
dev@67 294 const findMax = <T>(arr: FoldsToNumber<T>, getElement: (x: T) => number): number => {
dev@67 295 return arr.reduce((min, val) => Math.max(min, getElement(val)), -Infinity);
dev@67 296 };
dev@67 297
dev@67 298 const min = findMin<Feature>(featureData, (x: Feature) => {
dev@67 299 return findMin<number>(x.featureValues, y => y);
dev@67 300 });
dev@67 301
dev@67 302 const max = findMax<Feature>(featureData, (x: Feature) => {
dev@67 303 return findMax<number>(x.featureValues, y => y);
dev@67 304 });
dev@67 305
dev@67 306 const barHeight = 1.0 / height;
dev@64 307 return [
dev@67 308 featureData.reduce((bars, feature) => {
dev@67 309 const staticProperties = {
dev@64 310 x: toSeconds(feature.timestamp),
dev@64 311 width: toSeconds(feature.duration),
dev@67 312 height: min + barHeight,
dev@64 313 color: colour,
dev@64 314 opacity: 0.8
dev@67 315 };
dev@67 316 // TODO avoid copying Float32Array to an array - map is problematic here
dev@67 317 return bars.concat([...feature.featureValues]
dev@67 318 .map(val => Object.assign({}, staticProperties, {y: val})))
dev@67 319 }, []),
dev@67 320 {yDomain: [min, max + barHeight], height: height} as any
dev@67 321 ];
dev@64 322 } else {
dev@64 323 return [featureData.map(feature => {
dev@64 324 return {
dev@64 325 x: toSeconds(feature.timestamp),
dev@64 326 width: toSeconds(feature.duration),
dev@64 327 color: colour,
dev@64 328 opacity: 0.8
dev@64 329 }
dev@64 330 }), {height: height}];
dev@64 331 }
dev@64 332 };
dev@64 333
dev@64 334 let segmentLayer = new wavesUI.helpers.SegmentLayer(
dev@64 335 ...getSegmentArgs()
dev@64 336 );
dev@64 337 this.colouredLayers.set(this.addLayer(
dev@64 338 segmentLayer,
dev@64 339 mainTrack,
dev@64 340 this.timeline.timeContext
dev@64 341 ), colour);
dev@64 342 }
dev@64 343 break;
dev@64 344 }
cannam@106 345 case 'matrix': {
cannam@106 346 const stepDuration = (features as FixedSpacedFeatures).stepDuration;
cannam@106 347 const matrixData = (features.data as Float32Array[]);
cannam@106 348 if (matrixData.length === 0) return;
cannam@106 349 const matrixEntity = new wavesUI.utils.PrefilledMatrixEntity(matrixData);
cannam@106 350 let matrixLayer = new wavesUI.helpers.MatrixLayer(matrixEntity, {
cannam@106 351 color: colour,
cannam@106 352 height: height
cannam@106 353 });
cannam@106 354 this.colouredLayers.set(this.addLayer(
cannam@106 355 matrixLayer,
cannam@106 356 mainTrack,
cannam@106 357 this.timeline.timeContext
cannam@106 358 ), colour);
cannam@106 359 break;
cannam@106 360 }
dev@67 361 default:
cannam@106 362 console.log("Cannot render an appropriate layer for feature shape '" +
cannam@106 363 features.shape + "'");
dev@63 364 }
dev@59 365
dev@56 366 this.timeline.tracks.update();
dev@53 367 }
dev@53 368
dev@53 369 private animate(): void {
dev@31 370 this.ngZone.runOutsideAngular(() => {
dev@31 371 // listen for time passing...
dev@31 372 const updateSeekingCursor = () => {
dev@53 373 const currentTime = this.audioService.getCurrentTime();
dev@53 374 this.cursorLayer.currentPosition = currentTime;
dev@53 375 this.cursorLayer.update();
dev@53 376
dev@53 377 const currentOffset = this.timeline.timeContext.offset;
dev@53 378 const offsetTimestamp = currentOffset
dev@53 379 + currentTime;
dev@53 380
dev@53 381 const visibleDuration = this.timeline.timeContext.visibleDuration;
dev@53 382 // TODO reduce duplication between directions and make more declarative
dev@53 383 // this kinda logic should also be tested
dev@53 384 const mustPageForward = offsetTimestamp > visibleDuration;
dev@53 385 const mustPageBackward = currentTime < -currentOffset;
dev@53 386
dev@53 387 if (mustPageForward) {
dev@53 388 const hasSkippedMultiplePages = offsetTimestamp - visibleDuration > visibleDuration;
dev@53 389
cannam@106 390 this.timeline.timeContext.offset = hasSkippedMultiplePages ?
cannam@106 391 -currentTime + 0.5 * visibleDuration :
cannam@106 392 currentOffset - visibleDuration;
dev@51 393 this.timeline.tracks.update();
dev@34 394 }
dev@53 395
dev@53 396 if (mustPageBackward) {
dev@53 397 const hasSkippedMultiplePages = currentTime + visibleDuration < -currentOffset;
cannam@106 398 this.timeline.timeContext.offset = hasSkippedMultiplePages ?
cannam@106 399 -currentTime + 0.5 * visibleDuration :
cannam@106 400 currentOffset + visibleDuration;
dev@51 401 this.timeline.tracks.update();
dev@34 402 }
dev@53 403
dev@53 404 if (this.isPlaying)
dev@53 405 requestAnimationFrame(updateSeekingCursor);
dev@31 406 };
dev@31 407 updateSeekingCursor();
dev@31 408 });
dev@6 409 }
dev@16 410
dev@59 411 private addLayer(layer: Layer, track: Track, timeContext: any, isAxis: boolean = false): DisposableIndex {
dev@54 412 timeContext.zoom = 1.0;
dev@54 413 if (!layer.timeContext) {
dev@54 414 layer.setTimeContext(isAxis ?
dev@54 415 timeContext : new wavesUI.core.LayerTimeContext(timeContext));
dev@54 416 }
dev@54 417 track.add(layer);
dev@54 418 layer.render();
dev@54 419 layer.update();
dev@59 420 return this.disposableLayers.push(layer) - 1;
dev@59 421 }
dev@59 422
dev@59 423 private static changeColour(layer: Layer, colour: string): void {
dev@59 424 const butcherShapes = (shape) => {
dev@59 425 shape.install({color: () => colour});
dev@59 426 shape.params.color = colour;
dev@59 427 shape.update(layer._renderingContext, layer.data);
dev@59 428 };
dev@59 429
dev@59 430 layer._$itemCommonShapeMap.forEach(butcherShapes);
dev@59 431 layer._$itemShapeMap.forEach(butcherShapes);
dev@59 432 layer.render();
dev@59 433 layer.update();
dev@54 434 }
dev@54 435
dev@51 436 ngOnDestroy(): void {
dev@51 437 this.featureExtractionSubscription.unsubscribe();
dev@53 438 this.playingStateSubscription.unsubscribe();
dev@53 439 this.seekedSubscription.unsubscribe();
dev@51 440 }
dev@6 441 }