comparison src/app/waveform/waveform.component.ts @ 196:aa1c92c553cb

A few different @Input flags allowing for using component for just a waveform or features or both, turning off seeking and allowing more than one feature to be extracted to the component. Very messy, desperately needs refactoring.
author Lucas Thompson <dev@lucas.im>
date Fri, 24 Mar 2017 11:00:54 +0000
parents a50feba0d7f0
children bdfd4b4b7130
comparison
equal deleted inserted replaced
195:3ba03d9f0059 196:aa1c92c553cb
1 import { 1 import {
2 Component, OnInit, ViewChild, ElementRef, Input, AfterViewInit, NgZone, 2 Component, OnInit, ViewChild, ElementRef, Input, AfterViewInit, NgZone,
3 OnDestroy 3 OnDestroy
4 } from '@angular/core'; 4 } from '@angular/core';
5 import {AudioPlayerService} from "../services/audio-player/audio-player.service"; 5 import {
6 AudioPlayerService, AudioResource,
7 AudioResourceError
8 } from "../services/audio-player/audio-player.service";
6 import wavesUI from 'waves-ui'; 9 import wavesUI from 'waves-ui';
7 import { 10 import {
8 FeatureExtractionService 11 FeatureExtractionService
9 } from "../services/feature-extraction/feature-extraction.service"; 12 } from "../services/feature-extraction/feature-extraction.service";
10 import {Subscription} from "rxjs"; 13 import {Subscription} from "rxjs";
30 33
31 @ViewChild('track') trackDiv: ElementRef; 34 @ViewChild('track') trackDiv: ElementRef;
32 35
33 @Input() timeline: Timeline; 36 @Input() timeline: Timeline;
34 @Input() trackIdPrefix: string; 37 @Input() trackIdPrefix: string;
35 private _audioBuffer: AudioBuffer; 38 @Input() set isSubscribedToExtractionService(isSubscribed: boolean) {
36 private cursorLayer: any; 39 if (isSubscribed) {
37 private layers: Layer[]; 40 if (this.featureExtractionSubscription) {
38 41 return;
39 @Input() 42 }
43
44 const colours = function* () {
45 const circularColours = [
46 'black',
47 'red',
48 'green',
49 'purple',
50 'orange'
51 ];
52 let index = 0;
53 const nColours = circularColours.length;
54 while (true) {
55 yield circularColours[index = ++index % nColours];
56 }
57 }();
58
59 this.featureExtractionSubscription =
60 this.piperService.featuresExtracted$.subscribe(
61 features => {
62 this.renderFeatures(features, colours.next().value);
63 });
64 } else {
65 if (this.featureExtractionSubscription) {
66 this.featureExtractionSubscription.unsubscribe();
67 }
68 }
69 }
70 @Input() set isSubscribedToAudioService(isSubscribed: boolean) {
71 this._isSubscribedToAudioService = isSubscribed;
72 if (isSubscribed) {
73 if (this.onAudioDataSubscription) {
74 return;
75 }
76
77 this.onAudioDataSubscription =
78 this.audioService.audioLoaded$.subscribe(res => {
79 const wasError = (res as AudioResourceError).message != null;
80
81 if (wasError) {
82 console.warn('No audio, display error?');
83 } else {
84 this.audioBuffer = (res as AudioResource).samples;
85 }
86 });
87 } else {
88 if (this.onAudioDataSubscription) {
89 this.onAudioDataSubscription.unsubscribe();
90 }
91 }
92 }
93
94 get isSubscribedToAudioService(): boolean {
95 return this._isSubscribedToAudioService;
96 }
97
98 @Input() set isOneShotExtractor(isOneShot: boolean) {
99 this._isOneShotExtractor = isOneShot;
100 }
101
102 get isOneShotExtractor(): boolean {
103 return this._isOneShotExtractor;
104 }
105
106 @Input() set isSeeking(isSeeking: boolean) {
107 this._isSeeking = isSeeking;
108 if (isSeeking) {
109 if (this.seekedSubscription) {
110 return;
111 }
112 if(this.playingStateSubscription) {
113 return;
114 }
115
116 this.seekedSubscription = this.audioService.seeked$.subscribe(() => {
117 if (!this.isPlaying)
118 this.animate();
119 });
120 this.playingStateSubscription =
121 this.audioService.playingStateChange$.subscribe(
122 isPlaying => {
123 this.isPlaying = isPlaying;
124 if (this.isPlaying)
125 this.animate();
126 });
127 } else {
128 if (this.isPlaying) {
129 this.isPlaying = false;
130 }
131 if (this.playingStateSubscription) {
132 this.playingStateSubscription.unsubscribe();
133 }
134 if (this.seekedSubscription) {
135 this.seekedSubscription.unsubscribe();
136 }
137 }
138 }
139
140 get isSeeking(): boolean {
141 return this._isSeeking;
142 }
143
40 set audioBuffer(buffer: AudioBuffer) { 144 set audioBuffer(buffer: AudioBuffer) {
41 this._audioBuffer = buffer || undefined; 145 this._audioBuffer = buffer || undefined;
42 if (this.audioBuffer) { 146 if (this.audioBuffer) {
43 this.renderWaveform(this.audioBuffer); 147 this.renderWaveform(this.audioBuffer);
44 // this.renderSpectrogram(this.audioBuffer); 148 // this.renderSpectrogram(this.audioBuffer);
47 151
48 get audioBuffer(): AudioBuffer { 152 get audioBuffer(): AudioBuffer {
49 return this._audioBuffer; 153 return this._audioBuffer;
50 } 154 }
51 155
156 private _audioBuffer: AudioBuffer;
157 private _isSubscribedToAudioService: boolean;
158 private _isOneShotExtractor: boolean;
159 private _isSeeking: boolean;
160 private cursorLayer: any;
161 private layers: Layer[];
52 private featureExtractionSubscription: Subscription; 162 private featureExtractionSubscription: Subscription;
53 private playingStateSubscription: Subscription; 163 private playingStateSubscription: Subscription;
54 private seekedSubscription: Subscription; 164 private seekedSubscription: Subscription;
165 private onAudioDataSubscription: Subscription;
55 private isPlaying: boolean; 166 private isPlaying: boolean;
56 private offsetAtPanStart: number; 167 private offsetAtPanStart: number;
57 private initialZoom: number; 168 private initialZoom: number;
58 private initialDistance: number; 169 private initialDistance: number;
59 private zoomOnMouseDown: number; 170 private zoomOnMouseDown: number;
60 private offsetOnMouseDown: number; 171 private offsetOnMouseDown: number;
172 private hasShot: boolean;
173 private isLoading: boolean;
61 174
62 constructor(private audioService: AudioPlayerService, 175 constructor(private audioService: AudioPlayerService,
63 private piperService: FeatureExtractionService, 176 private piperService: FeatureExtractionService,
64 public ngZone: NgZone) { 177 public ngZone: NgZone) {
178 this.isSubscribedToAudioService = true;
179 this.isSeeking = true;
65 this.layers = []; 180 this.layers = [];
66 this._audioBuffer = undefined; 181 this.audioBuffer = undefined;
67 this.timeline = undefined; 182 this.timeline = undefined;
68 this.cursorLayer = undefined; 183 this.cursorLayer = undefined;
69 this.isPlaying = false; 184 this.isPlaying = false;
70 const colours = function* () { 185 this.isLoading = true;
71 const circularColours = [
72 'black',
73 'red',
74 'green',
75 'purple',
76 'orange'
77 ];
78 let index = 0;
79 const nColours = circularColours.length;
80 while (true) {
81 yield circularColours[index = ++index % nColours];
82 }
83 }();
84
85 this.featureExtractionSubscription = piperService.featuresExtracted$.subscribe(
86 features => {
87 this.renderFeatures(features, colours.next().value);
88 });
89 this.playingStateSubscription = audioService.playingStateChange$.subscribe(
90 isPlaying => {
91 this.isPlaying = isPlaying;
92 if (this.isPlaying)
93 this.animate();
94 });
95 this.seekedSubscription = audioService.seeked$.subscribe(() => {
96 if (!this.isPlaying)
97 this.animate();
98 });
99 } 186 }
100 187
101 ngOnInit() { 188 ngOnInit() {
102 } 189 }
103 190
104 ngAfterViewInit(): void { 191 ngAfterViewInit(): void {
105 this.trackIdPrefix = this.trackIdPrefix || "default"; 192 this.trackIdPrefix = this.trackIdPrefix || "default";
106 this.renderTimeline(); 193 if (this.timeline) {
107 } 194 this.renderTimeline(null, true, true);
108 195 } else {
109 renderTimeline(duration: number = 1.0): Timeline { 196 this.renderTimeline();
197 }
198 }
199
200 renderTimeline(duration: number = 1.0,
201 useExistingDuration: boolean = false,
202 isInitialRender: boolean = false): Timeline {
110 const track: HTMLElement = this.trackDiv.nativeElement; 203 const track: HTMLElement = this.trackDiv.nativeElement;
111 track.innerHTML = ""; 204 track.innerHTML = "";
112 const height: number = track.getBoundingClientRect().height; 205 const height: number = track.getBoundingClientRect().height;
113 const width: number = track.getBoundingClientRect().width; 206 const width: number = track.getBoundingClientRect().width;
114 const pixelsPerSecond = width / duration; 207 const pixelsPerSecond = width / duration;
115 if (this.timeline instanceof wavesUI.core.Timeline) { 208 const hasExistingTimeline = this.timeline instanceof wavesUI.core.Timeline;
116 this.timeline.pixelsPerSecond = pixelsPerSecond; 209
117 this.timeline.visibleWidth = width; 210 if (hasExistingTimeline) {
211 if (!useExistingDuration) {
212 this.timeline.pixelsPerSecond = pixelsPerSecond;
213 this.timeline.visibleWidth = width;
214 }
118 } else { 215 } else {
119 this.timeline = new wavesUI.core.Timeline(pixelsPerSecond, width); 216 this.timeline = new wavesUI.core.Timeline(pixelsPerSecond, width);
120 } 217 }
121 this.timeline.createTrack(track, height, `wave-${this.trackIdPrefix}`); 218 const waveTrack = this.timeline.createTrack(
219 track,
220 height,
221 `wave-${this.trackIdPrefix}`
222 );
223 if (isInitialRender && hasExistingTimeline) {
224 // time axis
225 const timeAxis = new wavesUI.helpers.TimeAxisLayer({
226 height: height,
227 color: '#b0b0b0'
228 });
229 this.addLayer(timeAxis, waveTrack, this.timeline.timeContext, true);
230 this.cursorLayer = new wavesUI.helpers.CursorLayer({
231 height: height
232 });
233 this.addLayer(this.cursorLayer, waveTrack, this.timeline.timeContext);
234 }
235 if ('ontouchstart' in window) {
236 interface Point {
237 x: number;
238 y: number;
239 }
240
241 let zoomGestureJustEnded: boolean = false;
242
243 const pixelToExponent: Function = wavesUI.utils.scales.linear()
244 .domain([0, 100]) // 100px => factor 2
245 .range([0, 1]);
246
247 const calculateDistance: (p1: Point, p2: Point) => number = (p1, p2) => {
248 return Math.pow(
249 Math.pow(p2.x - p1.x, 2) +
250 Math.pow(p2.y - p1.y, 2), 0.5);
251 };
252
253 const hammertime = new Hammer(this.trackDiv.nativeElement);
254 const scroll = (ev) => {
255 if (zoomGestureJustEnded) {
256 zoomGestureJustEnded = false;
257 console.log("Skip this event: likely a single touch dangling from pinch");
258 return;
259 }
260 this.timeline.timeContext.offset = this.offsetAtPanStart +
261 this.timeline.timeContext.timeToPixel.invert(ev.deltaX);
262 this.timeline.tracks.update();
263 };
264
265 const zoom = (ev) => {
266 const minZoom = this.timeline.state.minZoom;
267 const maxZoom = this.timeline.state.maxZoom;
268 const distance = calculateDistance({
269 x: ev.pointers[0].clientX,
270 y: ev.pointers[0].clientY
271 }, {
272 x: ev.pointers[1].clientX,
273 y: ev.pointers[1].clientY
274 });
275
276 const lastCenterTime =
277 this.timeline.timeContext.timeToPixel.invert(ev.center.x);
278
279 const exponent = pixelToExponent(distance - this.initialDistance);
280 const targetZoom = this.initialZoom * Math.pow(2, exponent);
281
282 this.timeline.timeContext.zoom =
283 Math.min(Math.max(targetZoom, minZoom), maxZoom);
284
285 const newCenterTime =
286 this.timeline.timeContext.timeToPixel.invert(ev.center.x);
287
288 this.timeline.timeContext.offset += newCenterTime - lastCenterTime;
289 this.timeline.tracks.update();
290 };
291 hammertime.get('pinch').set({ enable: true });
292 hammertime.on('panstart', () => {
293 this.offsetAtPanStart = this.timeline.timeContext.offset;
294 });
295 hammertime.on('panleft', scroll);
296 hammertime.on('panright', scroll);
297 hammertime.on('pinchstart', (e) => {
298 this.initialZoom = this.timeline.timeContext.zoom;
299
300 this.initialDistance = calculateDistance({
301 x: e.pointers[0].clientX,
302 y: e.pointers[0].clientY
303 }, {
304 x: e.pointers[1].clientX,
305 y: e.pointers[1].clientY
306 });
307 });
308 hammertime.on('pinch', zoom);
309 hammertime.on('pinchend', () => {
310 zoomGestureJustEnded = true;
311 });
312 }
122 // this.timeline.createTrack(track, height/2, `wave-${this.trackIdPrefix}`); 313 // this.timeline.createTrack(track, height/2, `wave-${this.trackIdPrefix}`);
123 // this.timeline.createTrack(track, height/2, `grid-${this.trackIdPrefix}`); 314 // this.timeline.createTrack(track, height/2, `grid-${this.trackIdPrefix}`);
124 } 315 }
125 316
126 estimatePercentile(matrix, percentile) { 317 estimatePercentile(matrix, percentile) {
306 this.addLayer(this.cursorLayer, waveTrack, this.timeline.timeContext); 497 this.addLayer(this.cursorLayer, waveTrack, this.timeline.timeContext);
307 this.timeline.state = new wavesUI.states.CenteredZoomState(this.timeline); 498 this.timeline.state = new wavesUI.states.CenteredZoomState(this.timeline);
308 waveTrack.render(); 499 waveTrack.render();
309 waveTrack.update(); 500 waveTrack.update();
310 501
311 502 this.isLoading = false;
312 if ('ontouchstart' in window) {
313 interface Point {
314 x: number;
315 y: number;
316 }
317
318 let zoomGestureJustEnded: boolean = false;
319
320 const pixelToExponent: Function = wavesUI.utils.scales.linear()
321 .domain([0, 100]) // 100px => factor 2
322 .range([0, 1]);
323
324 const calculateDistance: (p1: Point, p2: Point) => number = (p1, p2) => {
325 return Math.pow(
326 Math.pow(p2.x - p1.x, 2) +
327 Math.pow(p2.y - p1.y, 2), 0.5);
328 };
329
330 const hammertime = new Hammer(this.trackDiv.nativeElement);
331 const scroll = (ev) => {
332 if (zoomGestureJustEnded) {
333 zoomGestureJustEnded = false;
334 console.log("Skip this event: likely a single touch dangling from pinch");
335 return;
336 }
337 this.timeline.timeContext.offset = this.offsetAtPanStart +
338 this.timeline.timeContext.timeToPixel.invert(ev.deltaX);
339 this.timeline.tracks.update();
340 };
341
342 const zoom = (ev) => {
343 const minZoom = this.timeline.state.minZoom;
344 const maxZoom = this.timeline.state.maxZoom;
345 const distance = calculateDistance({
346 x: ev.pointers[0].clientX,
347 y: ev.pointers[0].clientY
348 }, {
349 x: ev.pointers[1].clientX,
350 y: ev.pointers[1].clientY
351 });
352
353 const lastCenterTime =
354 this.timeline.timeContext.timeToPixel.invert(ev.center.x);
355
356 const exponent = pixelToExponent(distance - this.initialDistance);
357 const targetZoom = this.initialZoom * Math.pow(2, exponent);
358
359 this.timeline.timeContext.zoom =
360 Math.min(Math.max(targetZoom, minZoom), maxZoom);
361
362 const newCenterTime =
363 this.timeline.timeContext.timeToPixel.invert(ev.center.x);
364
365 this.timeline.timeContext.offset += newCenterTime - lastCenterTime;
366 this.timeline.tracks.update();
367 };
368 hammertime.get('pinch').set({ enable: true });
369 hammertime.on('panstart', () => {
370 this.offsetAtPanStart = this.timeline.timeContext.offset;
371 });
372 hammertime.on('panleft', scroll);
373 hammertime.on('panright', scroll);
374 hammertime.on('pinchstart', (e) => {
375 this.initialZoom = this.timeline.timeContext.zoom;
376
377 this.initialDistance = calculateDistance({
378 x: e.pointers[0].clientX,
379 y: e.pointers[0].clientY
380 }, {
381 x: e.pointers[1].clientX,
382 y: e.pointers[1].clientY
383 });
384 });
385 hammertime.on('pinch', zoom);
386 hammertime.on('pinchend', () => {
387 zoomGestureJustEnded = true;
388 });
389 }
390
391 this.animate(); 503 this.animate();
392 } 504 }
393 505
394 renderSpectrogram(buffer: AudioBuffer): void { 506 renderSpectrogram(buffer: AudioBuffer): void {
395 const height: number = this.trackDiv.nativeElement.getBoundingClientRect().height / 2; 507 const height: number = this.trackDiv.nativeElement.getBoundingClientRect().height / 2;
408 this.timeline.tracks.update(); 520 this.timeline.tracks.update();
409 } 521 }
410 522
411 // TODO refactor - this doesn't belong here 523 // TODO refactor - this doesn't belong here
412 private renderFeatures(extracted: SimpleResponse, colour: Colour): void { 524 private renderFeatures(extracted: SimpleResponse, colour: Colour): void {
525 if (this.isOneShotExtractor && !this.hasShot) {
526 this.featureExtractionSubscription.unsubscribe();
527 this.hasShot = true;
528 }
529
413 if (!extracted.hasOwnProperty('features') || !extracted.hasOwnProperty('outputDescriptor')) return; 530 if (!extracted.hasOwnProperty('features') || !extracted.hasOwnProperty('outputDescriptor')) return;
414 if (!extracted.features.hasOwnProperty('shape') || !extracted.features.hasOwnProperty('data')) return; 531 if (!extracted.features.hasOwnProperty('shape') || !extracted.features.hasOwnProperty('data')) return;
415 const features: FeatureCollection = (extracted.features as FeatureCollection); 532 const features: FeatureCollection = (extracted.features as FeatureCollection);
416 const outputDescriptor = extracted.outputDescriptor; 533 const outputDescriptor = extracted.outputDescriptor;
417 const height = this.trackDiv.nativeElement.getBoundingClientRect().height / 2; 534 // const height = this.trackDiv.nativeElement.getBoundingClientRect().height / 2;
535 const height = this.trackDiv.nativeElement.getBoundingClientRect().height;
418 const waveTrack = this.timeline.getTrackById(`wave-${this.trackIdPrefix}`); 536 const waveTrack = this.timeline.getTrackById(`wave-${this.trackIdPrefix}`);
419 537
420 // TODO refactor all of this 538 // TODO refactor all of this
421 switch (features.shape) { 539 switch (features.shape) {
422 case 'vector': { 540 case 'vector': {
582 default: 700 default:
583 console.log("Cannot render an appropriate layer for feature shape '" + 701 console.log("Cannot render an appropriate layer for feature shape '" +
584 features.shape + "'"); 702 features.shape + "'");
585 } 703 }
586 704
705 this.isLoading = false;
587 this.timeline.tracks.update(); 706 this.timeline.tracks.update();
588 } 707 }
589 708
590 private animate(): void { 709 private animate(): void {
710 if (!this.isSeeking) return;
711
591 this.ngZone.runOutsideAngular(() => { 712 this.ngZone.runOutsideAngular(() => {
592 // listen for time passing... 713 // listen for time passing...
593 const updateSeekingCursor = () => { 714 const updateSeekingCursor = () => {
594 const currentTime = this.audioService.getCurrentTime(); 715 const currentTime = this.audioService.getCurrentTime();
595 this.cursorLayer.currentPosition = currentTime; 716 this.cursorLayer.currentPosition = currentTime;
656 layer.render(); 777 layer.render();
657 layer.update(); 778 layer.update();
658 } 779 }
659 780
660 ngOnDestroy(): void { 781 ngOnDestroy(): void {
661 this.featureExtractionSubscription.unsubscribe(); 782 if (this.featureExtractionSubscription)
662 this.playingStateSubscription.unsubscribe(); 783 this.featureExtractionSubscription.unsubscribe();
663 this.seekedSubscription.unsubscribe(); 784 if (this.playingStateSubscription)
785 this.playingStateSubscription.unsubscribe();
786 if (this.seekedSubscription)
787 this.seekedSubscription.unsubscribe();
788 if (this.onAudioDataSubscription)
789 this.onAudioDataSubscription.unsubscribe();
664 } 790 }
665 791
666 seekStart(): void { 792 seekStart(): void {
667 this.zoomOnMouseDown = this.timeline.timeContext.zoom; 793 this.zoomOnMouseDown = this.timeline.timeContext.zoom;
668 this.offsetOnMouseDown = this.timeline.timeContext.offset; 794 this.offsetOnMouseDown = this.timeline.timeContext.offset;
679 } 805 }
680 806
681 seek(x: number): void { 807 seek(x: number): void {
682 if (this.timeline) { 808 if (this.timeline) {
683 const timeContext: any = this.timeline.timeContext; 809 const timeContext: any = this.timeline.timeContext;
684 this.audioService.seekTo( 810 if (this.isSeeking) {
685 timeContext.timeToPixel.invert(x)- timeContext.offset 811 this.audioService.seekTo(
686 ); 812 timeContext.timeToPixel.invert(x)- timeContext.offset
813 );
814 }
687 } 815 }
688 } 816 }
689 } 817 }