src/shapes/waveform.js
import BaseShape from './base-shape';
import Oversampler from '../utils/oversample';
const xhtmlNS = 'http://www.w3.org/1999/xhtml';
/**
* A shape to display a waveform. (for entity data)
*
* [example usage](./examples/layer-waveform.html)
*
* @todo - fix problems with canvas strategy.
*/
export default class Waveform extends BaseShape {
getClassName() { return 'waveform'; }
_getAccessorList() {
return {};
}
_getDefaults() {
return {
sampleRate: 44100,
color: '#000000',
opacity: 1,
peakCacheBlockSize: 32,
};
}
render(renderingContext) {
if (this.$el) { return this.$el; }
this.$el = document.createElementNS(this.ns, 'path');
this.$el.setAttributeNS(null, 'fill', 'none');
this.$el.setAttributeNS(null, 'stroke', this.params.color);
this.$el.style.opacity = this.params.opacity;
this.factor = 8;
this.oversampler = new Oversampler(this.factor);
return this.$el;
}
encache(samples) {
console.log("waveform encache called");
// The cache is an array of peak caches (holding the min and max
// values within each block for a given block size) with each peak
// cache represented as an object with blockSize, min array, and
// max array properties.
//
// For example:
//
// [ {
// blockSize: 16,
// max: [ 0.7, 0.5, 0.25, -0.1 ],
// min: [ 0.5, -0.1, -0.8, -0.2 ]
// }, {
// blockSize: 32,
// max: [ 0.7, 0.25 ],
// min: [ -0.1, -0.8 ]
// }
// ]
//
// As it happens we are only creating a cache with a single block
// size at the moment, but it's useful to record that block size
// in the cache rather than have to fix it here in the shape.
const before = performance.now();
const peakCacheFor = ((arr, blockSize) => {
let peaks = [], troughs = [];
const len = arr.length;
for (let i = 0; i < len; i = i + blockSize) {
let min = arr[i];
let max = arr[i];
for (let j = 0; j < blockSize; j++) {
let sample = arr[i + j];
if (sample < min) { min = sample; }
if (sample > max) { max = sample; }
}
peaks.push(max);
troughs.push(min);
}
return [ peaks, troughs ];
});
// For a single peak cache, experiment suggests smallish block
// sizes are better. There's no benefit in having multiple layers
// of cache (e.g. 32 and 512) unless update() can take advantage
// of both in a single summarise action (e.g. when asked for a
// read from 310 to 1050, start by reading single samples from 310
// to 320, then from the 32-sample cache from 320 to 512, then
// switch to the 512 sample cache, rather than having to read
// single samples all the way from 310 to 512)... but at the
// moment it can't. And the more complex logic would carry its own
// overhead.
const blockSize = this.params.peakCacheBlockSize;
let [ peaks, troughs ] = peakCacheFor(samples, blockSize);
return {
samples,
peakCaches: [
{ blockSize,
max: peaks,
min: troughs
}
]
};
}
summarise(cache, minX, maxX, pixelToSample) {
const before = performance.now();
const samples = cache.samples;
const px0 = Math.floor(minX);
const px1 = Math.floor(maxX);
let peakCache = null;
let peakCacheBlockSize = 0;
if (cache && (cache.peakCaches.length > 0)) {
// Find a suitable peak cache if we have one.
// "step" is the distance in samples from one pixel to the next.
// We want the largest cache whose block size is no larger than
// half this, so as to avoid situations where our step is always
// straddling cache block boundaries.
const step = pixelToSample(px0 + 1) - pixelToSample(px0);
for (var i = 0; i < cache.peakCaches.length; ++i) {
const blockSize = cache.peakCaches[i].blockSize;
if (blockSize > peakCacheBlockSize && blockSize <= step/2) {
peakCache = cache.peakCaches[i];
peakCacheBlockSize = peakCache.blockSize;
}
}
}
const sampleRate = this.params.sampleRate;
let minMax = [];
for (let px = px0; px < px1; px++) {
const startSample = pixelToSample(px);
if (startSample < 0) continue;
if (startSample >= samples.length) break;
let endSample = pixelToSample(px + 1);
if (endSample >= samples.length) endSample = samples.length;
if (endSample < 0) continue;
let min = samples[startSample];
let max = min;
let ix = startSample;
if (peakCache && (peakCacheBlockSize > 0)) {
while (ix < endSample && (ix % peakCacheBlockSize) !== 0) {
let sample = samples[ix];
if (sample < min) { min = sample; }
if (sample > max) { max = sample; }
++ix;
}
let cacheIx = ix / peakCacheBlockSize;
const cacheMax = peakCache.max;
const cacheMin = peakCache.min;
while (ix + peakCacheBlockSize <= endSample) {
if (cacheMax[cacheIx] > max) max = cacheMax[cacheIx];
if (cacheMin[cacheIx] < min) min = cacheMin[cacheIx];
++cacheIx;
ix = ix + peakCacheBlockSize;
}
}
while (ix < endSample) {
let sample = samples[ix];
if (sample < min) { min = sample; }
if (sample > max) { max = sample; }
++ix;
}
minMax.push([px, min, max]);
}
const after = performance.now();
console.log("waveform summarisation time = " + Math.round(after - before));
return minMax;
}
_updateSummarising(renderingContext, cache, pixelToSample) {
console.log("waveform updateSummarising");
const minX = renderingContext.minX;
const maxX = renderingContext.maxX;
// get min/max values per pixel
const minMax = this.summarise(cache, minX, maxX, pixelToSample);
if (!minMax.length) { return; }
let instructions = minMax.map(datum => {
const [ x, min, max ] = datum;
const y1 = Math.round(renderingContext.valueToPixel(min));
const y2 = Math.round(renderingContext.valueToPixel(max));
return `${x},${y1}L${x},${y2}`;
});
const d = 'M' + instructions.join('L');
this.$el.setAttributeNS(null, 'shape-rendering', 'crispEdges');
this.$el.setAttributeNS(null, 'stroke-width', 1.0);
this.$el.setAttributeNS(null, 'd', d);
}
_updateInterpolating(renderingContext, cache, pixelToSample, sampleToPixel) {
console.log("waveform updateInterpolating");
const minX = renderingContext.minX;
const maxX = renderingContext.maxX;
const s0 = pixelToSample(minX);
const s1 = pixelToSample(maxX) + 1;
const samples = cache.samples;
const n = samples.length;
console.log("minX = " + minX + ", maxX = " + maxX + ", s0 = " + s0 + ", s1 = " + s1);
let instructions = [];
// Pixel coordinates in this function are *not* rounded, we want
// to preserve the proper shape as far as possible
// Add a little square for each sample location
for (let i = s0; i < s1 && i < n; ++i) {
if (i < 0) continue;
const x = sampleToPixel(i);
const y = renderingContext.valueToPixel(samples[i]);
instructions.push(`M${x-1},${y-1}h2v2h-2v-2`);
}
// Now fill in the gaps between the squares
const factor = this.factor;
const oversampled = this.oversampler.oversample(samples, s0, s1 - s0);
for (let i = 0; i < oversampled.length; ++i) {
const x = sampleToPixel(s0 + i/factor); // sampleToPixel accepts non-integers
const y = renderingContext.valueToPixel(oversampled[i]);
if (i === 0) {
instructions.push(`M${x},${y}`);
} else {
instructions.push(`L${x},${y}`);
}
}
const d = instructions.join('');
this.$el.setAttributeNS(null, 'shape-rendering', 'geometricPrecision');
this.$el.setAttributeNS(null, 'stroke-width', 0.6);
this.$el.setAttributeNS(null, 'd', d);
}
update(renderingContext, cache) {
console.log("waveform update called");
const before = performance.now();
const sampleRate = this.params.sampleRate;
const minX = renderingContext.minX;
const step = sampleRate * (renderingContext.timeToPixel.invert(minX + 1) -
renderingContext.timeToPixel.invert(minX));
const snapToCacheBoundaries = (step >= this.params.peakCacheBlockSize * 2);
console.log("waveform update: pixel step = " + step + " samples, snapToCacheBoundaries = " + snapToCacheBoundaries);
const pixelToSampleSnapped = (pixel => {
return this.params.peakCacheBlockSize *
Math.floor ((sampleRate * renderingContext.timeToPixel.invert(pixel)) /
this.params.peakCacheBlockSize);
});
const pixelToSampleUnsnapped = (pixel => {
return Math.floor (sampleRate * renderingContext.timeToPixel.invert(pixel));
});
const pixelToSample = (snapToCacheBoundaries ?
pixelToSampleSnapped :
pixelToSampleUnsnapped);
const sampleToPixel = (sample => {
// neither snapped nor even rounded to integer pixel
return renderingContext.timeToPixel(sample / sampleRate);
});
if (step > 1.0) {
this._updateSummarising(renderingContext, cache,
pixelToSample);
} else {
this._updateInterpolating(renderingContext, cache,
pixelToSample, sampleToPixel);
}
const after = performance.now();
console.log("waveform update time = " + Math.round(after - before));
}
}