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@51
|
7 import {FeatureList} from "piper/Feature";
|
dev@51
|
8 import {FeatureExtractionService} from "../services/feature-extraction/feature-extraction.service";
|
dev@51
|
9 import {Subscription} from "rxjs";
|
dev@53
|
10 import {toSeconds} from "piper";
|
dev@8
|
11
|
dev@20
|
12 type Timeline = any; // TODO what type actually is it.. start a .d.ts for waves-ui?
|
dev@54
|
13 type Layer = any;
|
dev@54
|
14 type Track = any;
|
dev@59
|
15 type DisposableIndex = number;
|
dev@59
|
16 type Colour = string;
|
dev@6
|
17
|
dev@6
|
18 @Component({
|
dev@6
|
19 selector: 'app-waveform',
|
dev@6
|
20 templateUrl: './waveform.component.html',
|
dev@6
|
21 styleUrls: ['./waveform.component.css']
|
dev@6
|
22 })
|
dev@51
|
23 export class WaveformComponent implements OnInit, AfterViewInit, OnDestroy {
|
dev@20
|
24
|
dev@8
|
25 @ViewChild('track') trackDiv: ElementRef;
|
dev@6
|
26
|
dev@54
|
27 private _audioBuffer: AudioBuffer;
|
dev@54
|
28 private timeline: Timeline;
|
dev@54
|
29 private cursorLayer: any;
|
dev@54
|
30 private disposableLayers: Layer[];
|
dev@59
|
31 private colouredLayers: Map<DisposableIndex, Colour>;
|
dev@16
|
32
|
dev@16
|
33 @Input()
|
dev@16
|
34 set audioBuffer(buffer: AudioBuffer) {
|
dev@16
|
35 this._audioBuffer = buffer || undefined;
|
dev@20
|
36 if (this.audioBuffer)
|
dev@20
|
37 this.renderWaveform(this.audioBuffer);
|
dev@16
|
38 }
|
dev@16
|
39
|
dev@16
|
40 get audioBuffer(): AudioBuffer {
|
dev@16
|
41 return this._audioBuffer;
|
dev@16
|
42 }
|
dev@16
|
43
|
dev@51
|
44 private featureExtractionSubscription: Subscription;
|
dev@53
|
45 private playingStateSubscription: Subscription;
|
dev@53
|
46 private seekedSubscription: Subscription;
|
dev@53
|
47 private isPlaying: boolean;
|
dev@51
|
48
|
dev@31
|
49 constructor(private audioService: AudioPlayerService,
|
dev@51
|
50 private piperService: FeatureExtractionService,
|
dev@51
|
51 public ngZone: NgZone) {
|
dev@59
|
52 this.colouredLayers = new Map();
|
dev@54
|
53 this.disposableLayers = [];
|
dev@54
|
54 this._audioBuffer = undefined;
|
dev@54
|
55 this.timeline = undefined;
|
dev@54
|
56 this.cursorLayer = undefined;
|
dev@53
|
57 this.isPlaying = false;
|
dev@59
|
58 const colours = function* () {
|
dev@59
|
59 const circularColours = [
|
dev@59
|
60 'black',
|
dev@59
|
61 'red',
|
dev@59
|
62 'green',
|
dev@59
|
63 'purple',
|
dev@59
|
64 'orange'
|
dev@59
|
65 ];
|
dev@59
|
66 let index = 0;
|
dev@59
|
67 const nColours = circularColours.length;
|
dev@59
|
68 while (true) {
|
dev@59
|
69 yield circularColours[index = ++index % nColours];
|
dev@59
|
70 }
|
dev@59
|
71 }();
|
dev@59
|
72
|
dev@51
|
73 this.featureExtractionSubscription = piperService.featuresExtracted$.subscribe(
|
dev@51
|
74 features => {
|
dev@59
|
75 this.renderFeatures(features, colours.next().value);
|
dev@51
|
76 });
|
dev@53
|
77 this.playingStateSubscription = audioService.playingStateChange$.subscribe(
|
dev@53
|
78 isPlaying => {
|
dev@53
|
79 this.isPlaying = isPlaying;
|
dev@53
|
80 if (this.isPlaying)
|
dev@53
|
81 this.animate();
|
dev@53
|
82 });
|
dev@53
|
83 this.seekedSubscription = audioService.seeked$.subscribe(() => {
|
dev@53
|
84 if (!this.isPlaying)
|
dev@53
|
85 this.animate();
|
dev@53
|
86 });
|
dev@51
|
87 }
|
dev@51
|
88
|
dev@53
|
89 ngOnInit() {
|
dev@53
|
90 }
|
dev@10
|
91
|
dev@10
|
92 ngAfterViewInit(): void {
|
dev@51
|
93 this.timeline = this.renderTimeline();
|
dev@20
|
94 }
|
dev@20
|
95
|
dev@20
|
96 renderTimeline(duration: number = 1.0): Timeline {
|
dev@18
|
97 const track: HTMLElement = this.trackDiv.nativeElement;
|
dev@20
|
98 track.innerHTML = "";
|
dev@18
|
99 const height: number = track.getBoundingClientRect().height;
|
dev@18
|
100 const width: number = track.getBoundingClientRect().width;
|
dev@18
|
101 const pixelsPerSecond = width / duration;
|
dev@18
|
102 const timeline = new wavesUI.core.Timeline(pixelsPerSecond, width);
|
dev@33
|
103 timeline.timeContext.offset = 0.5 * timeline.timeContext.visibleDuration;
|
dev@18
|
104 timeline.createTrack(track, height, 'main');
|
dev@54
|
105 return timeline;
|
dev@54
|
106 }
|
dev@18
|
107
|
dev@54
|
108 renderWaveform(buffer: AudioBuffer): void {
|
dev@54
|
109 const height: number = this.trackDiv.nativeElement.getBoundingClientRect().height;
|
dev@54
|
110 const mainTrack = this.timeline.getTrackById('main');
|
dev@54
|
111 if (this.timeline) {
|
dev@54
|
112 // resize
|
dev@54
|
113 const width = this.trackDiv.nativeElement.getBoundingClientRect().width;
|
dev@55
|
114
|
dev@54
|
115 // loop through layers and remove them, waves-ui provides methods for this but it seems to not work properly
|
dev@55
|
116 const timeContextChildren = this.timeline.timeContext._children;
|
dev@55
|
117
|
dev@54
|
118 for (let i = 0; i < this.disposableLayers.length; ++i) {
|
dev@54
|
119 let layer = this.disposableLayers.pop();
|
dev@54
|
120 mainTrack.remove(layer);
|
dev@55
|
121
|
dev@55
|
122 const index = timeContextChildren.indexOf(layer.timeContext);
|
dev@55
|
123 if (index >= 0)
|
dev@55
|
124 timeContextChildren.splice(index, 1);
|
dev@54
|
125 layer.destroy();
|
dev@54
|
126 }
|
dev@59
|
127 this.colouredLayers.clear();
|
dev@59
|
128
|
dev@54
|
129 this.timeline.visibleWidth = width;
|
dev@54
|
130 this.timeline.pixelsPerSecond = width / buffer.duration;
|
dev@54
|
131 mainTrack.height = height;
|
dev@54
|
132 } else {
|
dev@54
|
133 this.timeline = this.renderTimeline(buffer.duration)
|
dev@54
|
134 }
|
dev@18
|
135 // time axis
|
dev@18
|
136 const timeAxis = new wavesUI.helpers.TimeAxisLayer({
|
dev@18
|
137 height: height,
|
dev@18
|
138 color: 'gray'
|
dev@18
|
139 });
|
dev@54
|
140 this.addLayer(timeAxis, mainTrack, this.timeline.timeContext, true);
|
dev@18
|
141
|
dev@20
|
142 const waveformLayer = new wavesUI.helpers.WaveformLayer(buffer, {
|
dev@10
|
143 top: 10,
|
dev@20
|
144 height: height * 0.9,
|
dev@16
|
145 color: 'darkblue'
|
dev@16
|
146 });
|
dev@54
|
147 this.addLayer(waveformLayer, mainTrack, this.timeline.timeContext);
|
dev@31
|
148
|
dev@53
|
149 this.cursorLayer = new wavesUI.helpers.CursorLayer({
|
dev@31
|
150 height: height
|
dev@31
|
151 });
|
dev@54
|
152 this.addLayer(this.cursorLayer, mainTrack, this.timeline.timeContext);
|
dev@51
|
153 this.timeline.state = new wavesUI.states.CenteredZoomState(this.timeline);
|
dev@54
|
154 mainTrack.render();
|
dev@54
|
155 mainTrack.update();
|
dev@53
|
156 this.animate();
|
dev@53
|
157 }
|
dev@53
|
158
|
dev@53
|
159 // TODO refactor - this doesn't belong here
|
dev@59
|
160 private renderFeatures(features: FeatureList, colour: Colour): void {
|
dev@58
|
161 const normalisationFactor = 1.0 /
|
dev@58
|
162 features.reduce((currentMax, feature) => {
|
dev@58
|
163 return (feature.featureValues)
|
dev@58
|
164 ? Math.max(currentMax, feature.featureValues[0])
|
dev@58
|
165 : currentMax;
|
dev@58
|
166 }, -Infinity);
|
dev@53
|
167 const plotData = features.map(feature => {
|
dev@53
|
168 return {
|
dev@53
|
169 cx: toSeconds(feature.timestamp),
|
dev@58
|
170 cy: feature.featureValues[0] * normalisationFactor
|
dev@53
|
171 };
|
dev@53
|
172 });
|
dev@59
|
173 let breakpointLayer = new wavesUI.helpers.BreakpointLayer(plotData, {
|
dev@59
|
174 color: colour
|
dev@59
|
175 });
|
dev@59
|
176 this.colouredLayers.set(this.addLayer(
|
dev@59
|
177 breakpointLayer,
|
dev@54
|
178 this.timeline.getTrackById('main'),
|
dev@54
|
179 this.timeline.timeContext
|
dev@59
|
180 ), colour);
|
dev@59
|
181
|
dev@56
|
182 this.timeline.tracks.update();
|
dev@53
|
183 }
|
dev@53
|
184
|
dev@53
|
185 private animate(): void {
|
dev@31
|
186 this.ngZone.runOutsideAngular(() => {
|
dev@31
|
187 // listen for time passing...
|
dev@31
|
188 const updateSeekingCursor = () => {
|
dev@53
|
189 const currentTime = this.audioService.getCurrentTime();
|
dev@53
|
190 this.cursorLayer.currentPosition = currentTime;
|
dev@53
|
191 this.cursorLayer.update();
|
dev@53
|
192
|
dev@53
|
193 const currentOffset = this.timeline.timeContext.offset;
|
dev@53
|
194 const offsetTimestamp = currentOffset
|
dev@53
|
195 + currentTime;
|
dev@53
|
196
|
dev@53
|
197 const visibleDuration = this.timeline.timeContext.visibleDuration;
|
dev@53
|
198 // TODO reduce duplication between directions and make more declarative
|
dev@53
|
199 // this kinda logic should also be tested
|
dev@53
|
200 const mustPageForward = offsetTimestamp > visibleDuration;
|
dev@53
|
201 const mustPageBackward = currentTime < -currentOffset;
|
dev@53
|
202
|
dev@53
|
203 if (mustPageForward) {
|
dev@53
|
204 const hasSkippedMultiplePages = offsetTimestamp - visibleDuration > visibleDuration;
|
dev@53
|
205
|
dev@53
|
206 this.timeline.timeContext.offset = hasSkippedMultiplePages
|
dev@53
|
207 ? -currentTime + 0.5 * visibleDuration
|
dev@53
|
208 : currentOffset - visibleDuration;
|
dev@51
|
209 this.timeline.tracks.update();
|
dev@34
|
210 }
|
dev@53
|
211
|
dev@53
|
212 if (mustPageBackward) {
|
dev@53
|
213 const hasSkippedMultiplePages = currentTime + visibleDuration < -currentOffset;
|
dev@53
|
214 this.timeline.timeContext.offset = hasSkippedMultiplePages
|
dev@53
|
215 ? -currentTime + 0.5 * visibleDuration
|
dev@53
|
216 : currentOffset + visibleDuration;
|
dev@51
|
217 this.timeline.tracks.update();
|
dev@34
|
218 }
|
dev@53
|
219
|
dev@53
|
220 if (this.isPlaying)
|
dev@53
|
221 requestAnimationFrame(updateSeekingCursor);
|
dev@31
|
222 };
|
dev@31
|
223 updateSeekingCursor();
|
dev@31
|
224 });
|
dev@6
|
225 }
|
dev@16
|
226
|
dev@59
|
227 private addLayer(layer: Layer, track: Track, timeContext: any, isAxis: boolean = false): DisposableIndex {
|
dev@54
|
228 timeContext.zoom = 1.0;
|
dev@54
|
229 if (!layer.timeContext) {
|
dev@54
|
230 layer.setTimeContext(isAxis ?
|
dev@54
|
231 timeContext : new wavesUI.core.LayerTimeContext(timeContext));
|
dev@54
|
232 }
|
dev@54
|
233 track.add(layer);
|
dev@54
|
234 layer.render();
|
dev@54
|
235 layer.update();
|
dev@59
|
236 return this.disposableLayers.push(layer) - 1;
|
dev@59
|
237 }
|
dev@59
|
238
|
dev@59
|
239 private static changeColour(layer: Layer, colour: string): void {
|
dev@59
|
240 const butcherShapes = (shape) => {
|
dev@59
|
241 shape.install({color: () => colour});
|
dev@59
|
242 shape.params.color = colour;
|
dev@59
|
243 shape.update(layer._renderingContext, layer.data);
|
dev@59
|
244 };
|
dev@59
|
245
|
dev@59
|
246 layer._$itemCommonShapeMap.forEach(butcherShapes);
|
dev@59
|
247 layer._$itemShapeMap.forEach(butcherShapes);
|
dev@59
|
248 layer.render();
|
dev@59
|
249 layer.update();
|
dev@54
|
250 }
|
dev@54
|
251
|
dev@51
|
252 ngOnDestroy(): void {
|
dev@51
|
253 this.featureExtractionSubscription.unsubscribe();
|
dev@53
|
254 this.playingStateSubscription.unsubscribe();
|
dev@53
|
255 this.seekedSubscription.unsubscribe();
|
dev@51
|
256 }
|
dev@6
|
257 }
|