annotate src/app/waveform/waveform.component.ts @ 64:270f59ef3b83

Incorporate recent piper changes and use output descriptor for some basic feature discrimination to render appropriate layers.
author Lucas Thompson <dev@lucas.im>
date Mon, 12 Dec 2016 00:41:37 +0000
parents dc07ec243491
children 1b8dec9f6fe6
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@64 16 import {FeatureList} from "piper/Feature";
dev@8 17
dev@20 18 type Timeline = any; // TODO what type actually is it.. start a .d.ts for waves-ui?
dev@54 19 type Layer = any;
dev@54 20 type Track = any;
dev@59 21 type DisposableIndex = number;
dev@59 22 type Colour = string;
dev@6 23
dev@6 24 @Component({
dev@6 25 selector: 'app-waveform',
dev@6 26 templateUrl: './waveform.component.html',
dev@6 27 styleUrls: ['./waveform.component.css']
dev@6 28 })
dev@51 29 export class WaveformComponent implements OnInit, AfterViewInit, OnDestroy {
dev@20 30
dev@8 31 @ViewChild('track') trackDiv: ElementRef;
dev@6 32
dev@54 33 private _audioBuffer: AudioBuffer;
dev@54 34 private timeline: Timeline;
dev@54 35 private cursorLayer: any;
dev@54 36 private disposableLayers: Layer[];
dev@59 37 private colouredLayers: Map<DisposableIndex, Colour>;
dev@16 38
dev@16 39 @Input()
dev@16 40 set audioBuffer(buffer: AudioBuffer) {
dev@16 41 this._audioBuffer = buffer || undefined;
dev@20 42 if (this.audioBuffer)
dev@20 43 this.renderWaveform(this.audioBuffer);
dev@16 44 }
dev@16 45
dev@16 46 get audioBuffer(): AudioBuffer {
dev@16 47 return this._audioBuffer;
dev@16 48 }
dev@16 49
dev@51 50 private featureExtractionSubscription: Subscription;
dev@53 51 private playingStateSubscription: Subscription;
dev@53 52 private seekedSubscription: Subscription;
dev@53 53 private isPlaying: boolean;
dev@51 54
dev@31 55 constructor(private audioService: AudioPlayerService,
dev@51 56 private piperService: FeatureExtractionService,
dev@51 57 public ngZone: NgZone) {
dev@59 58 this.colouredLayers = new Map();
dev@54 59 this.disposableLayers = [];
dev@54 60 this._audioBuffer = undefined;
dev@54 61 this.timeline = undefined;
dev@54 62 this.cursorLayer = undefined;
dev@53 63 this.isPlaying = false;
dev@59 64 const colours = function* () {
dev@59 65 const circularColours = [
dev@59 66 'black',
dev@59 67 'red',
dev@59 68 'green',
dev@59 69 'purple',
dev@59 70 'orange'
dev@59 71 ];
dev@59 72 let index = 0;
dev@59 73 const nColours = circularColours.length;
dev@59 74 while (true) {
dev@59 75 yield circularColours[index = ++index % nColours];
dev@59 76 }
dev@59 77 }();
dev@59 78
dev@51 79 this.featureExtractionSubscription = piperService.featuresExtracted$.subscribe(
dev@51 80 features => {
dev@59 81 this.renderFeatures(features, colours.next().value);
dev@51 82 });
dev@53 83 this.playingStateSubscription = audioService.playingStateChange$.subscribe(
dev@53 84 isPlaying => {
dev@53 85 this.isPlaying = isPlaying;
dev@53 86 if (this.isPlaying)
dev@53 87 this.animate();
dev@53 88 });
dev@53 89 this.seekedSubscription = audioService.seeked$.subscribe(() => {
dev@53 90 if (!this.isPlaying)
dev@53 91 this.animate();
dev@53 92 });
dev@51 93 }
dev@51 94
dev@53 95 ngOnInit() {
dev@53 96 }
dev@10 97
dev@10 98 ngAfterViewInit(): void {
dev@51 99 this.timeline = this.renderTimeline();
dev@20 100 }
dev@20 101
dev@20 102 renderTimeline(duration: number = 1.0): Timeline {
dev@18 103 const track: HTMLElement = this.trackDiv.nativeElement;
dev@20 104 track.innerHTML = "";
dev@18 105 const height: number = track.getBoundingClientRect().height;
dev@18 106 const width: number = track.getBoundingClientRect().width;
dev@18 107 const pixelsPerSecond = width / duration;
dev@18 108 const timeline = new wavesUI.core.Timeline(pixelsPerSecond, width);
dev@33 109 timeline.timeContext.offset = 0.5 * timeline.timeContext.visibleDuration;
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@18 141 // time axis
dev@18 142 const timeAxis = new wavesUI.helpers.TimeAxisLayer({
dev@18 143 height: height,
dev@18 144 color: 'gray'
dev@18 145 });
dev@54 146 this.addLayer(timeAxis, mainTrack, this.timeline.timeContext, true);
dev@18 147
dev@20 148 const waveformLayer = new wavesUI.helpers.WaveformLayer(buffer, {
dev@10 149 top: 10,
dev@20 150 height: height * 0.9,
dev@16 151 color: 'darkblue'
dev@16 152 });
dev@54 153 this.addLayer(waveformLayer, mainTrack, this.timeline.timeContext);
dev@31 154
dev@53 155 this.cursorLayer = new wavesUI.helpers.CursorLayer({
dev@31 156 height: height
dev@31 157 });
dev@54 158 this.addLayer(this.cursorLayer, mainTrack, this.timeline.timeContext);
dev@51 159 this.timeline.state = new wavesUI.states.CenteredZoomState(this.timeline);
dev@54 160 mainTrack.render();
dev@54 161 mainTrack.update();
dev@53 162 this.animate();
dev@53 163 }
dev@53 164
dev@53 165 // TODO refactor - this doesn't belong here
dev@64 166 private renderFeatures(extracted: SimpleResponse, colour: Colour): void {
dev@64 167 if (!extracted.hasOwnProperty('features') || !extracted.hasOwnProperty('outputDescriptor')) return;
dev@64 168 if (!extracted.features.hasOwnProperty('shape') || !extracted.features.hasOwnProperty('data')) return;
dev@64 169 const features: FeatureCollection = (extracted.features as FeatureCollection);
dev@64 170 const outputDescriptor = extracted.outputDescriptor;
dev@64 171 const height = this.trackDiv.nativeElement.getBoundingClientRect().height;
dev@64 172 const mainTrack = this.timeline.getTrackById('main');
dev@64 173
dev@64 174 // TODO refactor all of this
dev@63 175 switch (features.shape) {
dev@64 176 case 'vector': {
dev@63 177 const stepDuration = (features as FixedSpacedFeatures).stepDuration;
dev@63 178 const featureData = (features.data as Float32Array);
dev@63 179 const normalisationFactor = 1.0 /
dev@63 180 featureData.reduce(
dev@63 181 (currentMax, feature) => Math.max(currentMax, feature),
dev@63 182 -Infinity
dev@63 183 );
dev@63 184 const plotData = [...featureData].map((feature, i) => {
dev@63 185 return {
dev@63 186 cx: i * stepDuration,
dev@63 187 cy: feature * normalisationFactor
dev@63 188 };
dev@63 189 });
dev@63 190 let breakpointLayer = new wavesUI.helpers.BreakpointLayer(plotData, {
dev@63 191 color: colour,
dev@64 192 height: height
dev@63 193 });
dev@63 194 this.colouredLayers.set(this.addLayer(
dev@63 195 breakpointLayer,
dev@64 196 mainTrack,
dev@63 197 this.timeline.timeContext
dev@63 198 ), colour);
dev@63 199 break;
dev@64 200 }
dev@64 201 case 'list': {
dev@64 202 const featureData = (features.data as FeatureList);
dev@64 203 // TODO look at output descriptor instead of directly inspecting features
dev@64 204 const hasDuration = outputDescriptor.configured.hasDuration;
dev@64 205 const isMarker = !hasDuration
dev@64 206 && outputDescriptor.configured.binCount === 0
dev@64 207 && featureData[0].featureValues == null;
dev@64 208 const isRegion = hasDuration
dev@64 209 && featureData[0].timestamp != null;
dev@64 210
dev@64 211 // TODO refactor, this is incomprehensible
dev@64 212 if (isMarker) {
dev@64 213 const plotData = featureData.map(feature => {
dev@64 214 return {x: toSeconds(feature.timestamp)}
dev@64 215 });
dev@64 216 let markerLayer = new wavesUI.helpers.MarkerLayer(plotData, {
dev@64 217 height: height,
dev@64 218 color: colour,
dev@64 219 });
dev@64 220 this.colouredLayers.set(this.addLayer(
dev@64 221 markerLayer,
dev@64 222 mainTrack,
dev@64 223 this.timeline.timeContext
dev@64 224 ), colour);
dev@64 225 } else if (isRegion) {
dev@64 226 const isBarRegion = featureData[0].featureValues.length === 1;
dev@64 227 const getSegmentArgs = () => {
dev@64 228 if (isBarRegion) {
dev@64 229 const min = featureData.reduce((min, feature) =>
dev@64 230 Math.min(min, feature.featureValues[0]),
dev@64 231 Infinity
dev@64 232 );
dev@64 233
dev@64 234 const max = featureData.reduce((max, feature) =>
dev@64 235 Math.max(max, feature.featureValues[0]),
dev@64 236 -Infinity
dev@64 237 );
dev@64 238
dev@64 239 return [
dev@64 240 featureData.map(feature => {
dev@64 241 return {
dev@64 242 x: toSeconds(feature.timestamp),
dev@64 243 y: feature.featureValues[0],
dev@64 244 width: toSeconds(feature.duration),
dev@64 245 height: 0.05 * max,
dev@64 246 color: colour,
dev@64 247 opacity: 0.8
dev@64 248 }
dev@64 249 }),
dev@64 250 {yDomain: [min, max + 0.05 * max], height: height} as any
dev@64 251 ]
dev@64 252 } else {
dev@64 253 return [featureData.map(feature => {
dev@64 254 return {
dev@64 255 x: toSeconds(feature.timestamp),
dev@64 256 width: toSeconds(feature.duration),
dev@64 257 color: colour,
dev@64 258 opacity: 0.8
dev@64 259 }
dev@64 260 }), {height: height}];
dev@64 261 }
dev@64 262 };
dev@64 263
dev@64 264 let segmentLayer = new wavesUI.helpers.SegmentLayer(
dev@64 265 ...getSegmentArgs()
dev@64 266 );
dev@64 267 this.colouredLayers.set(this.addLayer(
dev@64 268 segmentLayer,
dev@64 269 mainTrack,
dev@64 270 this.timeline.timeContext
dev@64 271 ), colour);
dev@64 272 }
dev@64 273
dev@64 274 break;
dev@64 275 }
dev@63 276 }
dev@59 277
dev@56 278 this.timeline.tracks.update();
dev@53 279 }
dev@53 280
dev@53 281 private animate(): void {
dev@31 282 this.ngZone.runOutsideAngular(() => {
dev@31 283 // listen for time passing...
dev@31 284 const updateSeekingCursor = () => {
dev@53 285 const currentTime = this.audioService.getCurrentTime();
dev@53 286 this.cursorLayer.currentPosition = currentTime;
dev@53 287 this.cursorLayer.update();
dev@53 288
dev@53 289 const currentOffset = this.timeline.timeContext.offset;
dev@53 290 const offsetTimestamp = currentOffset
dev@53 291 + currentTime;
dev@53 292
dev@53 293 const visibleDuration = this.timeline.timeContext.visibleDuration;
dev@53 294 // TODO reduce duplication between directions and make more declarative
dev@53 295 // this kinda logic should also be tested
dev@53 296 const mustPageForward = offsetTimestamp > visibleDuration;
dev@53 297 const mustPageBackward = currentTime < -currentOffset;
dev@53 298
dev@53 299 if (mustPageForward) {
dev@53 300 const hasSkippedMultiplePages = offsetTimestamp - visibleDuration > visibleDuration;
dev@53 301
dev@53 302 this.timeline.timeContext.offset = hasSkippedMultiplePages
dev@53 303 ? -currentTime + 0.5 * visibleDuration
dev@53 304 : currentOffset - visibleDuration;
dev@51 305 this.timeline.tracks.update();
dev@34 306 }
dev@53 307
dev@53 308 if (mustPageBackward) {
dev@53 309 const hasSkippedMultiplePages = currentTime + visibleDuration < -currentOffset;
dev@53 310 this.timeline.timeContext.offset = hasSkippedMultiplePages
dev@53 311 ? -currentTime + 0.5 * visibleDuration
dev@53 312 : currentOffset + visibleDuration;
dev@51 313 this.timeline.tracks.update();
dev@34 314 }
dev@53 315
dev@53 316 if (this.isPlaying)
dev@53 317 requestAnimationFrame(updateSeekingCursor);
dev@31 318 };
dev@31 319 updateSeekingCursor();
dev@31 320 });
dev@6 321 }
dev@16 322
dev@59 323 private addLayer(layer: Layer, track: Track, timeContext: any, isAxis: boolean = false): DisposableIndex {
dev@54 324 timeContext.zoom = 1.0;
dev@54 325 if (!layer.timeContext) {
dev@54 326 layer.setTimeContext(isAxis ?
dev@54 327 timeContext : new wavesUI.core.LayerTimeContext(timeContext));
dev@54 328 }
dev@54 329 track.add(layer);
dev@54 330 layer.render();
dev@54 331 layer.update();
dev@59 332 return this.disposableLayers.push(layer) - 1;
dev@59 333 }
dev@59 334
dev@59 335 private static changeColour(layer: Layer, colour: string): void {
dev@59 336 const butcherShapes = (shape) => {
dev@59 337 shape.install({color: () => colour});
dev@59 338 shape.params.color = colour;
dev@59 339 shape.update(layer._renderingContext, layer.data);
dev@59 340 };
dev@59 341
dev@59 342 layer._$itemCommonShapeMap.forEach(butcherShapes);
dev@59 343 layer._$itemShapeMap.forEach(butcherShapes);
dev@59 344 layer.render();
dev@59 345 layer.update();
dev@54 346 }
dev@54 347
dev@51 348 ngOnDestroy(): void {
dev@51 349 this.featureExtractionSubscription.unsubscribe();
dev@53 350 this.playingStateSubscription.unsubscribe();
dev@53 351 this.seekedSubscription.unsubscribe();
dev@51 352 }
dev@6 353 }