annotate src/app/waveform/waveform.component.ts @ 296:cda9307d9eb7

Start updating code to piper-js api with updated FeatureCollection
author Chris Cannam <cannam@all-day-breakfast.com>
date Wed, 10 May 2017 13:41:18 +0100
parents 71f9286da66f
children bc67503ea8c6 fff3a4fba992
rev   line source
dev@10 1 import {
dev@236 2 Component,
dev@236 3 OnInit,
dev@236 4 ViewChild,
dev@236 5 ElementRef,
dev@236 6 Input,
dev@236 7 AfterViewInit,
dev@236 8 NgZone,
dev@236 9 OnDestroy,
dev@236 10 ChangeDetectorRef
dev@10 11 } from '@angular/core';
dev@196 12 import {
dev@196 13 AudioPlayerService, AudioResource,
dev@196 14 AudioResourceError
dev@236 15 } from '../services/audio-player/audio-player.service';
dev@289 16 import wavesUI from 'waves-ui-piper';
dev@63 17 import {
dev@64 18 FeatureExtractionService
dev@236 19 } from '../services/feature-extraction/feature-extraction.service';
dev@236 20 import {Subscription} from 'rxjs/Subscription';
dev@63 21 import {
dev@63 22 FeatureCollection,
cannam@296 23 SimpleResponse,
cannam@296 24 VectorFeatures,
cannam@296 25 MatrixFeatures,
cannam@296 26 TrackFeature,
cannam@296 27 TrackFeatures
dev@236 28 } from 'piper/HigherLevelUtilities';
dev@236 29 import {toSeconds} from 'piper';
dev@236 30 import {FeatureList, Feature} from 'piper/Feature';
dev@81 31 import * as Hammer from 'hammerjs';
dev@236 32 import {WavesSpectrogramLayer} from '../spectrogram/Spectrogram';
dev@8 33
dev@54 34 type Layer = any;
dev@54 35 type Track = any;
dev@59 36 type Colour = string;
dev@6 37
dev@268 38
dev@268 39
dev@268 40 function* createColourGenerator(colours) {
cannam@257 41 let index = 0;
dev@268 42 const nColours = colours.length;
cannam@257 43 while (true) {
dev@268 44 yield colours[index = ++index % nColours];
cannam@257 45 }
dev@268 46 }
dev@268 47
dev@268 48 const defaultColourGenerator = createColourGenerator([
dev@268 49 '#0868ac', // "sapphire blue", our waveform / header colour
dev@268 50 '#c33c54', // "brick red"
dev@268 51 '#17bebb', // "tiffany blue"
dev@268 52 '#001021', // "rich black"
dev@268 53 '#fa8334', // "mango tango"
dev@268 54 '#034748' // "deep jungle green"
dev@268 55 ]);
cannam@257 56
dev@6 57 @Component({
dev@236 58 selector: 'ugly-waveform',
dev@6 59 templateUrl: './waveform.component.html',
dev@6 60 styleUrls: ['./waveform.component.css']
dev@6 61 })
cannam@257 62
dev@51 63 export class WaveformComponent implements OnInit, AfterViewInit, OnDestroy {
dev@20 64
dev@8 65 @ViewChild('track') trackDiv: ElementRef;
dev@285 66 @Input() set width(width: number) {
dev@285 67 if (this.timeline) {
dev@285 68 requestAnimationFrame(() => {
dev@285 69 this.timeline.timeContext.visibleWidth = width;
dev@285 70 this.timeline.tracks.update();
dev@285 71 });
dev@285 72 }
dev@285 73 }
dev@189 74 @Input() timeline: Timeline;
dev@189 75 @Input() trackIdPrefix: string;
dev@196 76 @Input() set isSubscribedToExtractionService(isSubscribed: boolean) {
dev@196 77 if (isSubscribed) {
dev@196 78 if (this.featureExtractionSubscription) {
dev@196 79 return;
dev@196 80 }
dev@268 81
dev@196 82 this.featureExtractionSubscription =
dev@196 83 this.piperService.featuresExtracted$.subscribe(
dev@196 84 features => {
dev@268 85 this.renderFeatures(features, defaultColourGenerator.next().value);
dev@196 86 });
dev@196 87 } else {
dev@196 88 if (this.featureExtractionSubscription) {
dev@196 89 this.featureExtractionSubscription.unsubscribe();
dev@196 90 }
dev@196 91 }
dev@196 92 }
dev@196 93 @Input() set isSubscribedToAudioService(isSubscribed: boolean) {
dev@196 94 this._isSubscribedToAudioService = isSubscribed;
dev@196 95 if (isSubscribed) {
dev@196 96 if (this.onAudioDataSubscription) {
dev@196 97 return;
dev@196 98 }
dev@196 99
dev@196 100 this.onAudioDataSubscription =
dev@196 101 this.audioService.audioLoaded$.subscribe(res => {
dev@196 102 const wasError = (res as AudioResourceError).message != null;
dev@196 103
dev@196 104 if (wasError) {
dev@196 105 console.warn('No audio, display error?');
dev@196 106 } else {
dev@196 107 this.audioBuffer = (res as AudioResource).samples;
dev@196 108 }
dev@196 109 });
dev@196 110 } else {
dev@196 111 if (this.onAudioDataSubscription) {
dev@196 112 this.onAudioDataSubscription.unsubscribe();
dev@196 113 }
dev@196 114 }
dev@196 115 }
dev@196 116
dev@196 117 get isSubscribedToAudioService(): boolean {
dev@196 118 return this._isSubscribedToAudioService;
dev@196 119 }
dev@196 120
dev@196 121 @Input() set isOneShotExtractor(isOneShot: boolean) {
dev@196 122 this._isOneShotExtractor = isOneShot;
dev@196 123 }
dev@196 124
dev@196 125 get isOneShotExtractor(): boolean {
dev@196 126 return this._isOneShotExtractor;
dev@196 127 }
dev@196 128
dev@196 129 @Input() set isSeeking(isSeeking: boolean) {
dev@196 130 this._isSeeking = isSeeking;
dev@196 131 if (isSeeking) {
dev@196 132 if (this.seekedSubscription) {
dev@196 133 return;
dev@196 134 }
dev@236 135 if (this.playingStateSubscription) {
dev@196 136 return;
dev@196 137 }
dev@196 138
dev@196 139 this.seekedSubscription = this.audioService.seeked$.subscribe(() => {
dev@236 140 if (!this.isPlaying) {
dev@196 141 this.animate();
dev@236 142 }
dev@196 143 });
dev@196 144 this.playingStateSubscription =
dev@196 145 this.audioService.playingStateChange$.subscribe(
dev@196 146 isPlaying => {
dev@196 147 this.isPlaying = isPlaying;
dev@236 148 if (this.isPlaying) {
dev@196 149 this.animate();
dev@236 150 }
dev@196 151 });
dev@196 152 } else {
dev@196 153 if (this.isPlaying) {
dev@196 154 this.isPlaying = false;
dev@196 155 }
dev@196 156 if (this.playingStateSubscription) {
dev@196 157 this.playingStateSubscription.unsubscribe();
dev@196 158 }
dev@196 159 if (this.seekedSubscription) {
dev@196 160 this.seekedSubscription.unsubscribe();
dev@196 161 }
dev@196 162 }
dev@196 163 }
dev@196 164
dev@196 165 get isSeeking(): boolean {
dev@196 166 return this._isSeeking;
dev@196 167 }
dev@196 168
dev@16 169 set audioBuffer(buffer: AudioBuffer) {
dev@16 170 this._audioBuffer = buffer || undefined;
cannam@117 171 if (this.audioBuffer) {
dev@20 172 this.renderWaveform(this.audioBuffer);
dev@180 173 // this.renderSpectrogram(this.audioBuffer);
cannam@117 174 }
dev@16 175 }
dev@16 176
dev@16 177 get audioBuffer(): AudioBuffer {
dev@16 178 return this._audioBuffer;
dev@16 179 }
dev@16 180
dev@196 181 private _audioBuffer: AudioBuffer;
dev@196 182 private _isSubscribedToAudioService: boolean;
dev@196 183 private _isOneShotExtractor: boolean;
dev@196 184 private _isSeeking: boolean;
dev@196 185 private cursorLayer: any;
cannam@254 186 private highlightLayer: any;
dev@196 187 private layers: Layer[];
dev@51 188 private featureExtractionSubscription: Subscription;
dev@53 189 private playingStateSubscription: Subscription;
dev@53 190 private seekedSubscription: Subscription;
dev@196 191 private onAudioDataSubscription: Subscription;
dev@53 192 private isPlaying: boolean;
dev@155 193 private zoomOnMouseDown: number;
dev@157 194 private offsetOnMouseDown: number;
dev@196 195 private hasShot: boolean;
dev@196 196 private isLoading: boolean;
dev@51 197
dev@236 198 private static changeColour(layer: Layer, colour: string): void {
dev@236 199 const butcherShapes = (shape) => {
dev@236 200 shape.install({color: () => colour});
dev@236 201 shape.params.color = colour;
dev@236 202 shape.update(layer._renderingContext, layer.data);
dev@236 203 };
dev@236 204
dev@236 205 layer._$itemCommonShapeMap.forEach(butcherShapes);
dev@236 206 layer._$itemShapeMap.forEach(butcherShapes);
dev@236 207 layer.render();
dev@236 208 layer.update();
dev@236 209 }
dev@236 210
dev@31 211 constructor(private audioService: AudioPlayerService,
dev@51 212 private piperService: FeatureExtractionService,
dev@234 213 private ngZone: NgZone,
dev@234 214 private ref: ChangeDetectorRef) {
dev@196 215 this.isSubscribedToAudioService = true;
dev@196 216 this.isSeeking = true;
dev@185 217 this.layers = [];
dev@196 218 this.audioBuffer = undefined;
dev@54 219 this.timeline = undefined;
dev@54 220 this.cursorLayer = undefined;
cannam@254 221 this.highlightLayer = undefined;
dev@53 222 this.isPlaying = false;
dev@196 223 this.isLoading = true;
dev@51 224 }
dev@51 225
dev@53 226 ngOnInit() {
dev@53 227 }
dev@10 228
dev@10 229 ngAfterViewInit(): void {
dev@236 230 this.trackIdPrefix = this.trackIdPrefix || 'default';
dev@196 231 if (this.timeline) {
dev@196 232 this.renderTimeline(null, true, true);
dev@196 233 } else {
dev@196 234 this.renderTimeline();
dev@196 235 }
dev@20 236 }
dev@20 237
dev@196 238 renderTimeline(duration: number = 1.0,
dev@196 239 useExistingDuration: boolean = false,
dev@196 240 isInitialRender: boolean = false): Timeline {
dev@18 241 const track: HTMLElement = this.trackDiv.nativeElement;
dev@236 242 track.innerHTML = '';
dev@18 243 const height: number = track.getBoundingClientRect().height;
dev@18 244 const width: number = track.getBoundingClientRect().width;
dev@18 245 const pixelsPerSecond = width / duration;
dev@196 246 const hasExistingTimeline = this.timeline instanceof wavesUI.core.Timeline;
dev@196 247
dev@196 248 if (hasExistingTimeline) {
dev@196 249 if (!useExistingDuration) {
dev@196 250 this.timeline.pixelsPerSecond = pixelsPerSecond;
dev@196 251 this.timeline.visibleWidth = width;
dev@196 252 }
dev@180 253 } else {
dev@180 254 this.timeline = new wavesUI.core.Timeline(pixelsPerSecond, width);
dev@180 255 }
dev@196 256 const waveTrack = this.timeline.createTrack(
dev@196 257 track,
dev@196 258 height,
dev@196 259 `wave-${this.trackIdPrefix}`
dev@196 260 );
dev@196 261 if (isInitialRender && hasExistingTimeline) {
dev@196 262 // time axis
dev@196 263 const timeAxis = new wavesUI.helpers.TimeAxisLayer({
dev@196 264 height: height,
dev@196 265 color: '#b0b0b0'
dev@196 266 });
dev@196 267 this.addLayer(timeAxis, waveTrack, this.timeline.timeContext, true);
dev@196 268 this.cursorLayer = new wavesUI.helpers.CursorLayer({
cannam@257 269 height: height,
cannam@257 270 color: '#c33c54'
dev@196 271 });
dev@196 272 this.addLayer(this.cursorLayer, waveTrack, this.timeline.timeContext);
dev@196 273 }
dev@196 274 if ('ontouchstart' in window) {
dev@196 275 interface Point {
dev@196 276 x: number;
dev@196 277 y: number;
dev@196 278 }
dev@196 279
dev@236 280 let zoomGestureJustEnded = false;
dev@196 281
dev@196 282 const pixelToExponent: Function = wavesUI.utils.scales.linear()
dev@196 283 .domain([0, 100]) // 100px => factor 2
dev@196 284 .range([0, 1]);
dev@196 285
dev@196 286 const calculateDistance: (p1: Point, p2: Point) => number = (p1, p2) => {
dev@196 287 return Math.pow(
dev@196 288 Math.pow(p2.x - p1.x, 2) +
dev@196 289 Math.pow(p2.y - p1.y, 2), 0.5);
dev@196 290 };
dev@196 291
dev@205 292 const calculateMidPoint: (p1: Point, p2: Point) => Point = (p1, p2) => {
dev@205 293 return {
dev@205 294 x: 0.5 * (p1.x + p2.x),
dev@205 295 y: 0.5 * (p1.y + p2.y)
dev@205 296 };
dev@205 297 };
dev@205 298
dev@205 299 const hammertime = new Hammer.Manager(this.trackDiv.nativeElement, {
dev@205 300 recognizers: [
dev@205 301 [Hammer.Pan, { direction: Hammer.DIRECTION_HORIZONTAL }]
dev@205 302 ]
dev@205 303 });
dev@204 304
dev@204 305 // it seems HammerJs binds the event to the window?
dev@204 306 // causing these events to propagate to other components?
dev@204 307 const componentTimeline = this.timeline;
dev@204 308 let initialZoom;
dev@204 309 let initialDistance;
dev@204 310 let offsetAtPanStart;
dev@205 311 let startX;
dev@205 312 let isZooming;
dev@204 313
dev@196 314 const scroll = (ev) => {
dev@236 315 if (ev.center.x - startX === 0) {
dev@236 316 return;
dev@236 317 }
dev@236 318
dev@196 319 if (zoomGestureJustEnded) {
dev@196 320 zoomGestureJustEnded = false;
dev@236 321 console.log('Skip this event: likely a single touch dangling from pinch');
dev@196 322 return;
dev@196 323 }
dev@204 324 componentTimeline.timeContext.offset = offsetAtPanStart +
dev@204 325 componentTimeline.timeContext.timeToPixel.invert(ev.deltaX);
dev@204 326 componentTimeline.tracks.update();
dev@196 327 };
dev@196 328
dev@196 329 const zoom = (ev) => {
dev@236 330 if (ev.touches.length < 2) {
dev@236 331 return;
dev@236 332 }
dev@236 333
dev@214 334 ev.preventDefault();
dev@204 335 const minZoom = componentTimeline.state.minZoom;
dev@204 336 const maxZoom = componentTimeline.state.maxZoom;
dev@205 337 const p1: Point = {
dev@218 338 x: ev.touches[0].clientX,
dev@218 339 y: ev.touches[0].clientY
dev@205 340 };
dev@205 341 const p2: Point = {
dev@218 342 x: ev.touches[1].clientX,
dev@218 343 y: ev.touches[1].clientY
dev@205 344 };
dev@205 345 const distance = calculateDistance(p1, p2);
dev@205 346 const midPoint = calculateMidPoint(p1, p2);
dev@196 347
dev@196 348 const lastCenterTime =
dev@205 349 componentTimeline.timeContext.timeToPixel.invert(midPoint.x);
dev@196 350
dev@204 351 const exponent = pixelToExponent(distance - initialDistance);
dev@204 352 const targetZoom = initialZoom * Math.pow(2, exponent);
dev@196 353
dev@204 354 componentTimeline.timeContext.zoom =
dev@196 355 Math.min(Math.max(targetZoom, minZoom), maxZoom);
dev@196 356
dev@196 357 const newCenterTime =
dev@205 358 componentTimeline.timeContext.timeToPixel.invert(midPoint.x);
dev@196 359
dev@204 360 componentTimeline.timeContext.offset += newCenterTime - lastCenterTime;
dev@204 361 componentTimeline.tracks.update();
dev@196 362 };
dev@205 363 hammertime.on('panstart', (ev) => {
dev@204 364 offsetAtPanStart = componentTimeline.timeContext.offset;
dev@205 365 startX = ev.center.x;
dev@196 366 });
dev@196 367 hammertime.on('panleft', scroll);
dev@196 368 hammertime.on('panright', scroll);
dev@205 369
dev@205 370
dev@205 371 const element: HTMLElement = this.trackDiv.nativeElement;
dev@205 372 element.addEventListener('touchstart', (e) => {
dev@236 373 if (e.touches.length < 2) {
dev@236 374 return;
dev@236 375 }
dev@236 376
dev@205 377 isZooming = true;
dev@204 378 initialZoom = componentTimeline.timeContext.zoom;
dev@196 379
dev@204 380 initialDistance = calculateDistance({
dev@218 381 x: e.touches[0].clientX,
dev@218 382 y: e.touches[0].clientY
dev@196 383 }, {
dev@218 384 x: e.touches[1].clientX,
dev@218 385 y: e.touches[1].clientY
dev@196 386 });
dev@196 387 });
dev@205 388 element.addEventListener('touchend', () => {
dev@205 389 if (isZooming) {
dev@205 390 isZooming = false;
dev@205 391 zoomGestureJustEnded = true;
dev@205 392 }
dev@205 393 });
dev@205 394 element.addEventListener('touchmove', zoom);
dev@196 395 }
dev@189 396 // this.timeline.createTrack(track, height/2, `wave-${this.trackIdPrefix}`);
dev@189 397 // this.timeline.createTrack(track, height/2, `grid-${this.trackIdPrefix}`);
dev@54 398 }
dev@18 399
cannam@108 400 estimatePercentile(matrix, percentile) {
cannam@108 401 // our sample is not evenly distributed across the whole data set:
cannam@108 402 // it is guaranteed to include at least one sample from every
cannam@108 403 // column, and could sample some values more than once. But it
cannam@108 404 // should be good enough in most cases (todo: show this)
cannam@109 405 if (matrix.length === 0) {
cannam@109 406 return 0.0;
cannam@109 407 }
cannam@108 408 const w = matrix.length;
cannam@108 409 const h = matrix[0].length;
cannam@108 410 const n = w * h;
cannam@109 411 const m = (n > 50000 ? 50000 : n); // should base that on the %ile
cannam@108 412 let m_per = Math.floor(m / w);
dev@236 413 if (m_per < 1) {
dev@236 414 m_per = 1;
dev@236 415 }
dev@236 416
dev@236 417 const sample = [];
cannam@108 418 for (let x = 0; x < w; ++x) {
cannam@108 419 for (let i = 0; i < m_per; ++i) {
cannam@108 420 const y = Math.floor(Math.random() * h);
cannam@109 421 const value = matrix[x][y];
cannam@109 422 if (!isNaN(value) && value !== Infinity) {
cannam@109 423 sample.push(value);
cannam@109 424 }
cannam@108 425 }
cannam@108 426 }
cannam@109 427 if (sample.length === 0) {
dev@236 428 console.log('WARNING: No samples gathered, even though we hoped for ' +
dev@236 429 (m_per * w) + ' of them');
cannam@109 430 return 0.0;
cannam@109 431 }
dev@236 432 sample.sort((a, b) => { return a - b; });
cannam@108 433 const ix = Math.floor((sample.length * percentile) / 100);
dev@236 434 console.log('Estimating ' + percentile + '-%ile of ' +
dev@236 435 n + '-sample dataset (' + w + ' x ' + h + ') as value ' + ix +
dev@236 436 ' of sorted ' + sample.length + '-sample subset');
cannam@108 437 const estimate = sample[ix];
dev@236 438 console.log('Estimate is: ' + estimate + ' (where min sampled value = ' +
dev@236 439 sample[0] + ' and max = ' + sample[sample.length - 1] + ')');
cannam@108 440 return estimate;
cannam@108 441 }
cannam@108 442
cannam@108 443 interpolatingMapper(hexColours) {
cannam@108 444 const colours = hexColours.map(n => {
cannam@108 445 const i = parseInt(n, 16);
cannam@118 446 return [ ((i >> 16) & 255) / 255.0,
cannam@118 447 ((i >> 8) & 255) / 255.0,
cannam@118 448 ((i) & 255) / 255.0 ];
cannam@108 449 });
cannam@108 450 const last = colours.length - 1;
cannam@108 451 return (value => {
cannam@108 452 const m = value * last;
cannam@108 453 if (m >= last) {
cannam@108 454 return colours[last];
cannam@108 455 }
cannam@108 456 if (m <= 0) {
cannam@108 457 return colours[0];
cannam@108 458 }
cannam@108 459 const base = Math.floor(m);
cannam@108 460 const prop0 = base + 1.0 - m;
cannam@108 461 const prop1 = m - base;
cannam@108 462 const c0 = colours[base];
dev@236 463 const c1 = colours[base + 1];
cannam@118 464 return [ c0[0] * prop0 + c1[0] * prop1,
cannam@118 465 c0[1] * prop0 + c1[1] * prop1,
cannam@118 466 c0[2] * prop0 + c1[2] * prop1 ];
cannam@108 467 });
cannam@108 468 }
dev@110 469
cannam@108 470 iceMapper() {
dev@236 471 const hexColours = [
cannam@108 472 // Based on ColorBrewer ylGnBu
dev@236 473 'ffffff', 'ffff00', 'f7fcf0', 'e0f3db', 'ccebc5', 'a8ddb5',
dev@236 474 '7bccc4', '4eb3d3', '2b8cbe', '0868ac', '084081', '042040'
cannam@108 475 ];
cannam@108 476 hexColours.reverse();
cannam@108 477 return this.interpolatingMapper(hexColours);
cannam@108 478 }
dev@110 479
cannam@118 480 hsv2rgb(h, s, v) { // all values in range [0, 1]
cannam@118 481 const i = Math.floor(h * 6);
cannam@118 482 const f = h * 6 - i;
cannam@118 483 const p = v * (1 - s);
cannam@118 484 const q = v * (1 - f * s);
cannam@118 485 const t = v * (1 - (1 - f) * s);
cannam@118 486 let r = 0, g = 0, b = 0;
cannam@118 487 switch (i % 6) {
dev@236 488 case 0: r = v; g = t; b = p; break;
dev@236 489 case 1: r = q; g = v; b = p; break;
dev@236 490 case 2: r = p; g = v; b = t; break;
dev@236 491 case 3: r = p; g = q; b = v; break;
dev@236 492 case 4: r = t; g = p; b = v; break;
dev@236 493 case 5: r = v; g = p; b = q; break;
cannam@118 494 }
cannam@118 495 return [ r, g, b ];
cannam@118 496 }
dev@122 497
cannam@118 498 greenMapper() {
cannam@118 499 const blue = 0.6666;
cannam@118 500 const pieslice = 0.3333;
cannam@118 501 return (value => {
cannam@118 502 const h = blue - value * 2.0 * pieslice;
cannam@118 503 const s = 0.5 + value / 2.0;
cannam@118 504 const v = value;
cannam@118 505 return this.hsv2rgb(h, s, v);
cannam@118 506 });
cannam@118 507 }
cannam@118 508
cannam@118 509 sunsetMapper() {
cannam@118 510 return (value => {
dev@236 511 const r = (value - 0.24) * 2.38;
dev@236 512 const g = (value - 0.64) * 2.777;
cannam@118 513 let b = (3.6 * value);
dev@236 514 if (value > 0.277) {
dev@236 515 b = 2.0 - b;
dev@236 516 }
cannam@118 517 return [ r, g, b ];
cannam@118 518 });
cannam@118 519 }
cannam@118 520
dev@122 521 clearTimeline(): void {
dev@122 522 // loop through layers and remove them, waves-ui provides methods for this but it seems to not work properly
dev@122 523 const timeContextChildren = this.timeline.timeContext._children;
dev@236 524 for (const track of this.timeline.tracks) {
dev@122 525 if (track.layers.length === 0) { continue; }
dev@122 526 const trackLayers = Array.from(track.layers);
dev@122 527 while (trackLayers.length) {
dev@236 528 const layer: Layer = trackLayers.pop();
dev@185 529 if (this.layers.includes(layer)) {
dev@185 530 track.remove(layer);
dev@185 531 this.layers.splice(this.layers.indexOf(layer), 1);
dev@185 532 const index = timeContextChildren.indexOf(layer.timeContext);
dev@185 533 if (index >= 0) {
dev@185 534 timeContextChildren.splice(index, 1);
dev@185 535 }
dev@185 536 layer.destroy();
dev@122 537 }
dev@122 538 }
dev@122 539 }
dev@122 540 }
dev@122 541
dev@54 542 renderWaveform(buffer: AudioBuffer): void {
dev@180 543 // const height: number = this.trackDiv.nativeElement.getBoundingClientRect().height / 2;
dev@180 544 const height: number = this.trackDiv.nativeElement.getBoundingClientRect().height;
dev@189 545 const waveTrack = this.timeline.getTrackById(`wave-${this.trackIdPrefix}`);
dev@54 546 if (this.timeline) {
dev@54 547 // resize
dev@54 548 const width = this.trackDiv.nativeElement.getBoundingClientRect().width;
dev@55 549
dev@122 550 this.clearTimeline();
dev@59 551
dev@54 552 this.timeline.visibleWidth = width;
dev@54 553 this.timeline.pixelsPerSecond = width / buffer.duration;
cannam@117 554 waveTrack.height = height;
dev@54 555 } else {
dev@236 556 this.renderTimeline(buffer.duration);
dev@54 557 }
dev@83 558 this.timeline.timeContext.offset = 0.5 * this.timeline.timeContext.visibleDuration;
cannam@106 559
dev@18 560 // time axis
dev@18 561 const timeAxis = new wavesUI.helpers.TimeAxisLayer({
dev@18 562 height: height,
cannam@106 563 color: '#b0b0b0'
dev@18 564 });
cannam@117 565 this.addLayer(timeAxis, waveTrack, this.timeline.timeContext, true);
dev@18 566
cannam@161 567 const nchannels = buffer.numberOfChannels;
cannam@161 568 const totalWaveHeight = height * 0.9;
cannam@161 569 const waveHeight = totalWaveHeight / nchannels;
dev@189 570
cannam@161 571 for (let ch = 0; ch < nchannels; ++ch) {
dev@236 572 console.log('about to construct a waveform layer for channel ' + ch);
cannam@161 573 const waveformLayer = new wavesUI.helpers.WaveformLayer(buffer, {
dev@236 574 top: (height - totalWaveHeight) / 2 + waveHeight * ch,
dev@236 575 height: waveHeight,
cannam@257 576 color: '#0868ac',
dev@236 577 channel: ch
cannam@161 578 });
cannam@161 579 this.addLayer(waveformLayer, waveTrack, this.timeline.timeContext);
cannam@161 580 }
cannam@117 581
dev@53 582 this.cursorLayer = new wavesUI.helpers.CursorLayer({
cannam@257 583 height: height,
cannam@257 584 color: '#c33c54'
dev@31 585 });
cannam@117 586 this.addLayer(this.cursorLayer, waveTrack, this.timeline.timeContext);
dev@51 587 this.timeline.state = new wavesUI.states.CenteredZoomState(this.timeline);
cannam@117 588 waveTrack.render();
cannam@117 589 waveTrack.update();
dev@81 590
dev@196 591 this.isLoading = false;
dev@234 592 this.ref.markForCheck();
dev@53 593 this.animate();
dev@53 594 }
dev@53 595
cannam@117 596 renderSpectrogram(buffer: AudioBuffer): void {
cannam@117 597 const height: number = this.trackDiv.nativeElement.getBoundingClientRect().height / 2;
dev@189 598 const gridTrack = this.timeline.getTrackById(`grid-${this.trackIdPrefix}`);
cannam@117 599
dev@129 600 const spectrogramLayer = new WavesSpectrogramLayer(buffer, {
cannam@221 601 top: 0,
cannam@221 602 height: height,
cannam@117 603 stepSize: 512,
dev@129 604 blockSize: 1024,
cannam@118 605 normalise: 'none',
cannam@118 606 mapper: this.sunsetMapper()
cannam@117 607 });
cannam@117 608 this.addLayer(spectrogramLayer, gridTrack, this.timeline.timeContext);
cannam@117 609
cannam@117 610 this.timeline.tracks.update();
cannam@117 611 }
cannam@117 612
dev@53 613 // TODO refactor - this doesn't belong here
dev@64 614 private renderFeatures(extracted: SimpleResponse, colour: Colour): void {
dev@196 615 if (this.isOneShotExtractor && !this.hasShot) {
dev@196 616 this.featureExtractionSubscription.unsubscribe();
dev@196 617 this.hasShot = true;
dev@196 618 }
dev@196 619
dev@236 620 if (!extracted.hasOwnProperty('features')
dev@236 621 || !extracted.hasOwnProperty('outputDescriptor')) {
dev@236 622 return;
dev@236 623 }
dev@236 624 if (!extracted.features.hasOwnProperty('shape')
dev@236 625 || !extracted.features.hasOwnProperty('data')) {
dev@236 626 return;
dev@236 627 }
dev@64 628 const features: FeatureCollection = (extracted.features as FeatureCollection);
dev@64 629 const outputDescriptor = extracted.outputDescriptor;
dev@196 630 // const height = this.trackDiv.nativeElement.getBoundingClientRect().height / 2;
dev@196 631 const height = this.trackDiv.nativeElement.getBoundingClientRect().height;
dev@189 632 const waveTrack = this.timeline.getTrackById(`wave-${this.trackIdPrefix}`);
dev@64 633
dev@64 634 // TODO refactor all of this
dev@63 635 switch (features.shape) {
cannam@296 636 case 'vector': {
cannam@296 637 const collected = features.collected as VectorFeatures;
cannam@296 638 const stepDuration = collected.stepDuration;
cannam@296 639 const featureData = collected.data;
dev@236 640 if (featureData.length === 0) {
dev@236 641 return;
dev@236 642 }
dev@63 643 const plotData = [...featureData].map((feature, i) => {
dev@63 644 return {
dev@63 645 cx: i * stepDuration,
cannam@258 646 cy: feature
dev@63 647 };
dev@63 648 });
cannam@258 649 let min = featureData.reduce((m, f) => Math.min(m, f), Infinity);
cannam@258 650 let max = featureData.reduce((m, f) => Math.max(m, f), -Infinity);
cannam@258 651 if (min === Infinity) {
cannam@258 652 min = 0;
cannam@258 653 max = 1;
cannam@258 654 }
cannam@288 655 console.log("adding line layer: min = " + min + ", max = " + max);
cannam@288 656 if (min !== min || max !== max) {
cannam@288 657 console.log("WARNING: min or max is NaN");
cannam@288 658 min = 0;
cannam@288 659 max = 1;
cannam@288 660 }
dev@236 661 const lineLayer = new wavesUI.helpers.LineLayer(plotData, {
dev@63 662 color: colour,
cannam@258 663 height: height,
cannam@258 664 yDomain: [ min, max ]
dev@63 665 });
dev@122 666 this.addLayer(
dev@105 667 lineLayer,
cannam@117 668 waveTrack,
dev@63 669 this.timeline.timeContext
dev@122 670 );
cannam@265 671 const scaleLayer = new wavesUI.helpers.ScaleLayer({
cannam@266 672 tickColor: colour,
cannam@266 673 textColor: colour,
cannam@264 674 height: height,
cannam@264 675 yDomain: [ min, max ]
cannam@264 676 });
cannam@264 677 this.addLayer(
cannam@264 678 scaleLayer,
cannam@264 679 waveTrack,
cannam@264 680 this.timeline.timeContext
cannam@264 681 );
cannam@254 682 this.highlightLayer = new wavesUI.helpers.HighlightLayer(lineLayer, {
cannam@254 683 opacity: 0.7,
cannam@257 684 height: height,
cannam@258 685 color: '#c33c54',
cannam@266 686 labelOffset: 38,
cannam@258 687 yDomain: [ min, max ]
cannam@254 688 });
cannam@254 689 this.addLayer(
cannam@254 690 this.highlightLayer,
cannam@254 691 waveTrack,
cannam@254 692 this.timeline.timeContext
cannam@254 693 );
dev@63 694 break;
dev@64 695 }
dev@64 696 case 'list': {
cannam@296 697 const featureData = features.collected as FeatureList;
dev@236 698 if (featureData.length === 0) {
dev@236 699 return;
dev@236 700 }
dev@64 701 // TODO look at output descriptor instead of directly inspecting features
dev@64 702 const hasDuration = outputDescriptor.configured.hasDuration;
dev@64 703 const isMarker = !hasDuration
dev@64 704 && outputDescriptor.configured.binCount === 0
dev@64 705 && featureData[0].featureValues == null;
dev@64 706 const isRegion = hasDuration
dev@64 707 && featureData[0].timestamp != null;
dev@236 708 console.log('Have list features: length ' + featureData.length +
dev@236 709 ', isMarker ' + isMarker + ', isRegion ' + isRegion +
dev@236 710 ', hasDuration ' + hasDuration);
dev@64 711 // TODO refactor, this is incomprehensible
dev@64 712 if (isMarker) {
dev@236 713 const plotData = featureData.map(feature => ({
dev@236 714 time: toSeconds(feature.timestamp),
dev@236 715 label: feature.label
dev@236 716 }));
dev@236 717 const featureLayer = new wavesUI.helpers.TickLayer(plotData, {
dev@64 718 height: height,
dev@64 719 color: colour,
cannam@152 720 labelPosition: 'bottom',
cannam@152 721 shadeSegments: true
dev@64 722 });
dev@122 723 this.addLayer(
cannam@149 724 featureLayer,
cannam@117 725 waveTrack,
dev@64 726 this.timeline.timeContext
dev@122 727 );
dev@64 728 } else if (isRegion) {
dev@236 729 console.log('Output is of region type');
dev@67 730 const binCount = outputDescriptor.configured.binCount || 0;
dev@67 731 const isBarRegion = featureData[0].featureValues.length >= 1 || binCount >= 1 ;
dev@64 732 const getSegmentArgs = () => {
dev@64 733 if (isBarRegion) {
dev@64 734
dev@67 735 // TODO refactor - this is messy
dev@67 736 interface FoldsToNumber<T> {
dev@67 737 reduce(fn: (previousValue: number,
dev@67 738 currentValue: T,
dev@67 739 currentIndex: number,
dev@67 740 array: ArrayLike<T>) => number,
dev@67 741 initialValue?: number): number;
dev@67 742 }
dev@64 743
dev@67 744 // TODO potentially change impl., i.e avoid reduce
dev@67 745 const findMin = <T>(arr: FoldsToNumber<T>, getElement: (x: T) => number): number => {
dev@67 746 return arr.reduce((min, val) => Math.min(min, getElement(val)), Infinity);
dev@67 747 };
dev@67 748
dev@67 749 const findMax = <T>(arr: FoldsToNumber<T>, getElement: (x: T) => number): number => {
dev@67 750 return arr.reduce((min, val) => Math.max(min, getElement(val)), -Infinity);
dev@67 751 };
dev@67 752
dev@67 753 const min = findMin<Feature>(featureData, (x: Feature) => {
dev@67 754 return findMin<number>(x.featureValues, y => y);
dev@67 755 });
dev@67 756
dev@67 757 const max = findMax<Feature>(featureData, (x: Feature) => {
dev@67 758 return findMax<number>(x.featureValues, y => y);
dev@67 759 });
dev@67 760
dev@67 761 const barHeight = 1.0 / height;
dev@64 762 return [
dev@67 763 featureData.reduce((bars, feature) => {
dev@67 764 const staticProperties = {
dev@64 765 x: toSeconds(feature.timestamp),
dev@64 766 width: toSeconds(feature.duration),
dev@67 767 height: min + barHeight,
dev@64 768 color: colour,
dev@64 769 opacity: 0.8
dev@67 770 };
dev@67 771 // TODO avoid copying Float32Array to an array - map is problematic here
dev@67 772 return bars.concat([...feature.featureValues]
dev@236 773 .map(val => Object.assign({}, staticProperties, {y: val})));
dev@67 774 }, []),
dev@67 775 {yDomain: [min, max + barHeight], height: height} as any
dev@67 776 ];
dev@64 777 } else {
dev@236 778 return [featureData.map(feature => ({
dev@236 779 x: toSeconds(feature.timestamp),
dev@236 780 width: toSeconds(feature.duration),
dev@236 781 color: colour,
dev@236 782 opacity: 0.8
dev@236 783 })), {height: height}];
dev@64 784 }
dev@64 785 };
dev@64 786
dev@236 787 const segmentLayer = new wavesUI.helpers.SegmentLayer(
dev@64 788 ...getSegmentArgs()
dev@64 789 );
dev@122 790 this.addLayer(
dev@64 791 segmentLayer,
cannam@117 792 waveTrack,
dev@64 793 this.timeline.timeContext
dev@122 794 );
dev@64 795 }
dev@64 796 break;
dev@64 797 }
cannam@106 798 case 'matrix': {
cannam@296 799 const collected = features.collected as MatrixFeatures;
cannam@296 800 const stepDuration = collected.stepDuration;
dev@236 801 // !!! + start time
cannam@296 802 const matrixData = collected.data;
dev@236 803
dev@236 804 if (matrixData.length === 0) {
dev@236 805 return;
dev@236 806 }
dev@236 807
dev@236 808 console.log('matrix data length = ' + matrixData.length);
dev@236 809 console.log('height of first column = ' + matrixData[0].length);
cannam@109 810 const targetValue = this.estimatePercentile(matrixData, 95);
cannam@108 811 const gain = (targetValue > 0.0 ? (1.0 / targetValue) : 1.0);
dev@236 812 console.log('setting gain to ' + gain);
cannam@120 813 const matrixEntity =
cannam@120 814 new wavesUI.utils.PrefilledMatrixEntity(matrixData,
cannam@120 815 0, // startTime
cannam@120 816 stepDuration);
dev@236 817 const matrixLayer = new wavesUI.helpers.MatrixLayer(matrixEntity, {
cannam@108 818 gain,
cannam@221 819 top: 0,
cannam@221 820 height: height,
cannam@109 821 normalise: 'none',
cannam@108 822 mapper: this.iceMapper()
cannam@108 823 });
dev@122 824 this.addLayer(
cannam@108 825 matrixLayer,
cannam@117 826 waveTrack,
cannam@108 827 this.timeline.timeContext
dev@122 828 );
cannam@108 829 break;
cannam@106 830 }
dev@67 831 default:
dev@236 832 console.log(
dev@236 833 `Cannot render an appropriate layer for feature shape '${features.shape}'`
dev@236 834 );
dev@63 835 }
dev@59 836
dev@196 837 this.isLoading = false;
dev@234 838 this.ref.markForCheck();
dev@56 839 this.timeline.tracks.update();
dev@53 840 }
dev@53 841
dev@53 842 private animate(): void {
dev@236 843 if (!this.isSeeking) {
dev@236 844 return;
dev@236 845 }
dev@196 846
dev@31 847 this.ngZone.runOutsideAngular(() => {
dev@31 848 // listen for time passing...
dev@31 849 const updateSeekingCursor = () => {
dev@53 850 const currentTime = this.audioService.getCurrentTime();
dev@53 851 this.cursorLayer.currentPosition = currentTime;
dev@53 852 this.cursorLayer.update();
dev@53 853
cannam@254 854 if (typeof(this.highlightLayer) !== 'undefined') {
cannam@254 855 this.highlightLayer.currentPosition = currentTime;
cannam@254 856 this.highlightLayer.update();
cannam@254 857 }
cannam@254 858
dev@53 859 const currentOffset = this.timeline.timeContext.offset;
dev@53 860 const offsetTimestamp = currentOffset
dev@53 861 + currentTime;
dev@53 862
dev@53 863 const visibleDuration = this.timeline.timeContext.visibleDuration;
dev@53 864 // TODO reduce duplication between directions and make more declarative
dev@53 865 // this kinda logic should also be tested
dev@53 866 const mustPageForward = offsetTimestamp > visibleDuration;
dev@53 867 const mustPageBackward = currentTime < -currentOffset;
dev@53 868
dev@53 869 if (mustPageForward) {
dev@53 870 const hasSkippedMultiplePages = offsetTimestamp - visibleDuration > visibleDuration;
dev@53 871
cannam@106 872 this.timeline.timeContext.offset = hasSkippedMultiplePages ?
cannam@106 873 -currentTime + 0.5 * visibleDuration :
cannam@106 874 currentOffset - visibleDuration;
dev@51 875 this.timeline.tracks.update();
dev@34 876 }
dev@53 877
dev@53 878 if (mustPageBackward) {
dev@53 879 const hasSkippedMultiplePages = currentTime + visibleDuration < -currentOffset;
cannam@106 880 this.timeline.timeContext.offset = hasSkippedMultiplePages ?
cannam@106 881 -currentTime + 0.5 * visibleDuration :
cannam@106 882 currentOffset + visibleDuration;
dev@51 883 this.timeline.tracks.update();
dev@34 884 }
dev@53 885
dev@236 886 if (this.isPlaying) {
dev@53 887 requestAnimationFrame(updateSeekingCursor);
dev@236 888 }
dev@31 889 };
dev@31 890 updateSeekingCursor();
dev@31 891 });
dev@6 892 }
dev@16 893
dev@122 894 private addLayer(layer: Layer, track: Track, timeContext: any, isAxis: boolean = false): void {
dev@54 895 timeContext.zoom = 1.0;
dev@54 896 if (!layer.timeContext) {
dev@54 897 layer.setTimeContext(isAxis ?
dev@54 898 timeContext : new wavesUI.core.LayerTimeContext(timeContext));
dev@54 899 }
dev@54 900 track.add(layer);
dev@185 901 this.layers.push(layer);
dev@54 902 layer.render();
dev@54 903 layer.update();
dev@122 904 if (this.cursorLayer && track.$layout.contains(this.cursorLayer.$el)) {
dev@112 905 track.$layout.appendChild(this.cursorLayer.$el);
dev@112 906 }
dev@59 907 }
dev@59 908
dev@51 909 ngOnDestroy(): void {
dev@236 910 if (this.featureExtractionSubscription) {
dev@196 911 this.featureExtractionSubscription.unsubscribe();
dev@236 912 }
dev@236 913 if (this.playingStateSubscription) {
dev@196 914 this.playingStateSubscription.unsubscribe();
dev@236 915 }
dev@236 916 if (this.seekedSubscription) {
dev@196 917 this.seekedSubscription.unsubscribe();
dev@236 918 }
dev@236 919 if (this.onAudioDataSubscription) {
dev@196 920 this.onAudioDataSubscription.unsubscribe();
dev@236 921 }
dev@51 922 }
dev@154 923
dev@155 924 seekStart(): void {
dev@155 925 this.zoomOnMouseDown = this.timeline.timeContext.zoom;
dev@157 926 this.offsetOnMouseDown = this.timeline.timeContext.offset;
dev@155 927 }
dev@155 928
dev@155 929 seekEnd(x: number): void {
dev@157 930 const hasSameZoom: boolean = this.zoomOnMouseDown ===
dev@157 931 this.timeline.timeContext.zoom;
dev@157 932 const hasSameOffset: boolean = this.offsetOnMouseDown ===
dev@157 933 this.timeline.timeContext.offset;
dev@157 934 if (hasSameZoom && hasSameOffset) {
dev@155 935 this.seek(x);
dev@155 936 }
dev@155 937 }
dev@155 938
dev@154 939 seek(x: number): void {
dev@154 940 if (this.timeline) {
dev@154 941 const timeContext: any = this.timeline.timeContext;
dev@196 942 if (this.isSeeking) {
dev@196 943 this.audioService.seekTo(
dev@236 944 timeContext.timeToPixel.invert(x) - timeContext.offset
dev@196 945 );
dev@196 946 }
dev@154 947 }
dev@154 948 }
dev@6 949 }