wavesurfer.js
Version:
Interactive navigable audio visualization using Web Audio and Canvas
618 lines (553 loc) • 21.6 kB
JavaScript
import Drawer from './drawer';
import * as util from './util';
import CanvasEntry from './drawer.canvasentry';
/**
* MultiCanvas renderer for wavesurfer. Is currently the default and sole
* builtin renderer.
*
* A `MultiCanvas` consists of one or more `CanvasEntry` instances, depending
* on the zoom level.
*/
export default class MultiCanvas extends Drawer {
/**
* @param {HTMLElement} container The container node of the wavesurfer instance
* @param {WavesurferParams} params The wavesurfer initialisation options
*/
constructor(container, params) {
super(container, params);
/**
* @type {number}
*/
this.maxCanvasWidth = params.maxCanvasWidth;
/**
* @type {number}
*/
this.maxCanvasElementWidth = Math.round(
params.maxCanvasWidth / params.pixelRatio
);
/**
* Whether or not the progress wave is rendered. If the `waveColor`
* and `progressColor` are the same color it is not.
*
* @type {boolean}
*/
this.hasProgressCanvas = params.waveColor != params.progressColor;
/**
* @type {number}
*/
this.halfPixel = 0.5 / params.pixelRatio;
/**
* List of `CanvasEntry` instances.
*
* @type {Array}
*/
this.canvases = [];
/**
* @type {HTMLElement}
*/
this.progressWave = null;
/**
* Class used to generate entries.
*
* @type {function}
*/
this.EntryClass = CanvasEntry;
/**
* Canvas 2d context attributes.
*
* @type {object}
*/
this.canvasContextAttributes = params.drawingContextAttributes;
/**
* Overlap added between entries to prevent vertical white stripes
* between `canvas` elements.
*
* @type {number}
*/
this.overlap = 2 * Math.ceil(params.pixelRatio / 2);
/**
* The radius of the wave bars. Makes bars rounded
*
* @type {number}
*/
this.barRadius = params.barRadius || 0;
/**
* Whether to render the waveform vertically. Defaults to false.
*
* @type {boolean}
*/
this.vertical = params.vertical;
}
/**
* Initialize the drawer
*/
init() {
this.createWrapper();
this.createElements();
}
/**
* Create the canvas elements and style them
*
*/
createElements() {
this.progressWave = util.withOrientation(
this.wrapper.appendChild(document.createElement('wave')),
this.params.vertical
);
this.style(this.progressWave, {
position: 'absolute',
zIndex: 3,
left: 0,
top: 0,
bottom: 0,
overflow: 'hidden',
width: '0',
display: 'none',
boxSizing: 'border-box',
borderRightStyle: 'solid',
pointerEvents: 'none'
});
this.addCanvas();
this.updateCursor();
}
/**
* Update cursor style
*/
updateCursor() {
this.style(this.progressWave, {
borderRightWidth: this.params.cursorWidth + 'px',
borderRightColor: this.params.cursorColor
});
}
/**
* Adjust to the updated size by adding or removing canvases
*/
updateSize() {
const totalWidth = Math.round(this.width / this.params.pixelRatio);
const requiredCanvases = Math.ceil(
totalWidth / (this.maxCanvasElementWidth + this.overlap)
);
// add required canvases
while (this.canvases.length < requiredCanvases) {
this.addCanvas();
}
// remove older existing canvases, if any
while (this.canvases.length > requiredCanvases) {
this.removeCanvas();
}
let canvasWidth = this.maxCanvasWidth + this.overlap;
const lastCanvas = this.canvases.length - 1;
this.canvases.forEach((entry, i) => {
if (i == lastCanvas) {
canvasWidth = this.width - this.maxCanvasWidth * lastCanvas;
}
this.updateDimensions(entry, canvasWidth, this.height);
entry.clearWave();
});
}
/**
* Add a canvas to the canvas list
*
*/
addCanvas() {
const entry = new this.EntryClass();
entry.canvasContextAttributes = this.canvasContextAttributes;
entry.hasProgressCanvas = this.hasProgressCanvas;
entry.halfPixel = this.halfPixel;
const leftOffset = this.maxCanvasElementWidth * this.canvases.length;
// wave
let wave = util.withOrientation(
this.wrapper.appendChild(document.createElement('canvas')),
this.params.vertical
);
this.style(wave, {
position: 'absolute',
zIndex: 2,
left: leftOffset + 'px',
top: 0,
bottom: 0,
height: '100%',
pointerEvents: 'none'
});
entry.initWave(wave);
// progress
if (this.hasProgressCanvas) {
let progress = util.withOrientation(
this.progressWave.appendChild(document.createElement('canvas')),
this.params.vertical
);
this.style(progress, {
position: 'absolute',
left: leftOffset + 'px',
top: 0,
bottom: 0,
height: '100%'
});
entry.initProgress(progress);
}
this.canvases.push(entry);
}
/**
* Pop single canvas from the list
*
*/
removeCanvas() {
let lastEntry = this.canvases[this.canvases.length - 1];
// wave
lastEntry.wave.parentElement.removeChild(lastEntry.wave.domElement);
// progress
if (this.hasProgressCanvas) {
lastEntry.progress.parentElement.removeChild(lastEntry.progress.domElement);
}
// cleanup
if (lastEntry) {
lastEntry.destroy();
lastEntry = null;
}
this.canvases.pop();
}
/**
* Update the dimensions of a canvas element
*
* @param {CanvasEntry} entry Target entry
* @param {number} width The new width of the element
* @param {number} height The new height of the element
*/
updateDimensions(entry, width, height) {
const elementWidth = Math.round(width / this.params.pixelRatio);
const totalWidth = Math.round(this.width / this.params.pixelRatio);
// update canvas dimensions
entry.updateDimensions(elementWidth, totalWidth, width, height);
// style element
this.style(this.progressWave, { display: 'block' });
}
/**
* Clear the whole multi-canvas
*/
clearWave() {
util.frame(() => {
this.canvases.forEach(entry => entry.clearWave());
})();
}
/**
* Draw a waveform with bars
*
* @param {number[]|Number.<Array[]>} peaks Can also be an array of arrays
* for split channel rendering
* @param {number} channelIndex The index of the current channel. Normally
* should be 0. Must be an integer.
* @param {number} start The x-offset of the beginning of the area that
* should be rendered
* @param {number} end The x-offset of the end of the area that should be
* rendered
* @returns {void}
*/
drawBars(peaks, channelIndex, start, end) {
return this.prepareDraw(
peaks,
channelIndex,
start,
end,
({ absmax, hasMinVals, height, offsetY, halfH, peaks, channelIndex: ch }) => {
// if drawBars was called within ws.empty we don't pass a start and
// don't want anything to happen
if (start === undefined) {
return;
}
// Skip every other value if there are negatives.
const peakIndexScale = hasMinVals ? 2 : 1;
const length = peaks.length / peakIndexScale;
const bar = this.params.barWidth * this.params.pixelRatio;
const gap =
this.params.barGap === null
? Math.max(this.params.pixelRatio, ~~(bar / 2))
: Math.max(
this.params.pixelRatio,
this.params.barGap * this.params.pixelRatio
);
const step = bar + gap;
const scale = length / this.width;
const first = start;
const last = end;
let peakIndex = first;
for (peakIndex; peakIndex < last; peakIndex += step) {
// search for the highest peak in the range this bar falls into
let peak = 0;
let peakIndexRange = Math.floor(peakIndex * scale) * peakIndexScale; // start index
const peakIndexEnd = Math.floor((peakIndex + step) * scale) * peakIndexScale;
do { // do..while makes sure at least one peak is always evaluated
const newPeak = Math.abs(peaks[peakIndexRange]); // for arrays starting with negative values
if (newPeak > peak) {
peak = newPeak; // higher
}
peakIndexRange += peakIndexScale; // skip every other value for negatives
} while (peakIndexRange < peakIndexEnd);
// calculate the height of this bar according to the highest peak found
let h = Math.round((peak / absmax) * halfH);
// raise the bar height to the specified minimum height
// Math.max is used to replace any value smaller than barMinHeight (not just 0) with barMinHeight
if (this.params.barMinHeight) {
h = Math.max(h, this.params.barMinHeight);
}
this.fillRect(
peakIndex + this.halfPixel,
halfH - h + offsetY,
bar + this.halfPixel,
h * 2,
this.barRadius,
ch
);
}
}
);
}
/**
* Draw a waveform
*
* @param {number[]|Number.<Array[]>} peaks Can also be an array of arrays
* for split channel rendering
* @param {number} channelIndex The index of the current channel. Normally
* should be 0
* @param {number?} start The x-offset of the beginning of the area that
* should be rendered (If this isn't set only a flat line is rendered)
* @param {number?} end The x-offset of the end of the area that should be
* rendered
* @returns {void}
*/
drawWave(peaks, channelIndex, start, end) {
return this.prepareDraw(
peaks,
channelIndex,
start,
end,
({ absmax, hasMinVals, height, offsetY, halfH, peaks, channelIndex }) => {
if (!hasMinVals) {
const reflectedPeaks = [];
const len = peaks.length;
let i = 0;
for (i; i < len; i++) {
reflectedPeaks[2 * i] = peaks[i];
reflectedPeaks[2 * i + 1] = -peaks[i];
}
peaks = reflectedPeaks;
}
// if drawWave was called within ws.empty we don't pass a start and
// end and simply want a flat line
if (start !== undefined) {
this.drawLine(peaks, absmax, halfH, offsetY, start, end, channelIndex);
}
// always draw a median line
this.fillRect(
0,
halfH + offsetY - this.halfPixel,
this.width,
this.halfPixel,
this.barRadius,
channelIndex
);
}
);
}
/**
* Tell the canvas entries to render their portion of the waveform
*
* @param {number[]} peaks Peaks data
* @param {number} absmax Maximum peak value (absolute)
* @param {number} halfH Half the height of the waveform
* @param {number} offsetY Offset to the top
* @param {number} start The x-offset of the beginning of the area that
* should be rendered
* @param {number} end The x-offset of the end of the area that
* should be rendered
* @param {channelIndex} channelIndex The channel index of the line drawn
*/
drawLine(peaks, absmax, halfH, offsetY, start, end, channelIndex) {
const { waveColor, progressColor } = this.params.splitChannelsOptions.channelColors[channelIndex] || {};
this.canvases.forEach((entry, i) => {
this.setFillStyles(entry, waveColor, progressColor);
this.applyCanvasTransforms(entry, this.params.vertical);
entry.drawLines(peaks, absmax, halfH, offsetY, start, end);
});
}
/**
* Draw a rectangle on the multi-canvas
*
* @param {number} x X-position of the rectangle
* @param {number} y Y-position of the rectangle
* @param {number} width Width of the rectangle
* @param {number} height Height of the rectangle
* @param {number} radius Radius of the rectangle
* @param {channelIndex} channelIndex The channel index of the bar drawn
*/
fillRect(x, y, width, height, radius, channelIndex) {
const startCanvas = Math.floor(x / this.maxCanvasWidth);
const endCanvas = Math.min(
Math.ceil((x + width) / this.maxCanvasWidth) + 1,
this.canvases.length
);
let i = startCanvas;
for (i; i < endCanvas; i++) {
const entry = this.canvases[i];
const leftOffset = i * this.maxCanvasWidth;
const intersection = {
x1: Math.max(x, i * this.maxCanvasWidth),
y1: y,
x2: Math.min(
x + width,
i * this.maxCanvasWidth + entry.wave.width
),
y2: y + height
};
if (intersection.x1 < intersection.x2) {
const { waveColor, progressColor } = this.params.splitChannelsOptions.channelColors[channelIndex] || {};
this.setFillStyles(entry, waveColor, progressColor);
this.applyCanvasTransforms(entry, this.params.vertical);
entry.fillRects(
intersection.x1 - leftOffset,
intersection.y1,
intersection.x2 - intersection.x1,
intersection.y2 - intersection.y1,
radius
);
}
}
}
/**
* Returns whether to hide the channel from being drawn based on params.
*
* @param {number} channelIndex The index of the current channel.
* @returns {bool} True to hide the channel, false to draw.
*/
hideChannel(channelIndex) {
return this.params.splitChannels && this.params.splitChannelsOptions.filterChannels.includes(channelIndex);
}
/**
* Performs preparation tasks and calculations which are shared by `drawBars`
* and `drawWave`
*
* @param {number[]|Number.<Array[]>} peaks Can also be an array of arrays for
* split channel rendering
* @param {number} channelIndex The index of the current channel. Normally
* should be 0
* @param {number?} start The x-offset of the beginning of the area that
* should be rendered. If this isn't set only a flat line is rendered
* @param {number?} end The x-offset of the end of the area that should be
* rendered
* @param {function} fn The render function to call, e.g. `drawWave`
* @param {number} drawIndex The index of the current channel after filtering.
* @param {number?} normalizedMax Maximum modulation value across channels for use with relativeNormalization. Ignored when undefined
* @returns {void}
*/
prepareDraw(peaks, channelIndex, start, end, fn, drawIndex, normalizedMax) {
return util.frame(() => {
// Split channels and call this function with the channelIndex set
if (peaks[0] instanceof Array) {
const channels = peaks;
if (this.params.splitChannels) {
const filteredChannels = channels.filter((c, i) => !this.hideChannel(i));
if (!this.params.splitChannelsOptions.overlay) {
this.setHeight(
Math.max(filteredChannels.length, 1) *
this.params.height *
this.params.pixelRatio
);
}
let overallAbsMax;
if (this.params.splitChannelsOptions && this.params.splitChannelsOptions.relativeNormalization) {
// calculate maximum peak across channels to use for normalization
overallAbsMax = util.max(channels.map((channelPeaks => util.absMax(channelPeaks))));
}
return channels.forEach((channelPeaks, i) =>
this.prepareDraw(channelPeaks, i, start, end, fn, filteredChannels.indexOf(channelPeaks), overallAbsMax)
);
}
peaks = channels[0];
}
// Return and do not draw channel peaks if hidden.
if (this.hideChannel(channelIndex)) {
return;
}
// calculate maximum modulation value, either from the barHeight
// parameter or if normalize=true from the largest value in the peak
// set
let absmax = 1 / this.params.barHeight;
if (this.params.normalize) {
absmax = normalizedMax === undefined ? util.absMax(peaks) : normalizedMax;
}
// Bar wave draws the bottom only as a reflection of the top,
// so we don't need negative values
const hasMinVals = [].some.call(peaks, val => val < 0);
const height = this.params.height * this.params.pixelRatio;
const halfH = height / 2;
let offsetY = height * drawIndex || 0;
// Override offsetY if overlay is true
if (this.params.splitChannelsOptions && this.params.splitChannelsOptions.overlay) {
offsetY = 0;
}
return fn({
absmax: absmax,
hasMinVals: hasMinVals,
height: height,
offsetY: offsetY,
halfH: halfH,
peaks: peaks,
channelIndex: channelIndex
});
})();
}
/**
* Set the fill styles for a certain entry (wave and progress)
*
* @param {CanvasEntry} entry Target entry
* @param {string} waveColor Wave color to draw this entry
* @param {string} progressColor Progress color to draw this entry
*/
setFillStyles(entry, waveColor = this.params.waveColor, progressColor = this.params.progressColor) {
entry.setFillStyles(waveColor, progressColor);
}
/**
* Set the canvas transforms for a certain entry (wave and progress)
*
* @param {CanvasEntry} entry Target entry
* @param {boolean} vertical Whether to render the waveform vertically
*/
applyCanvasTransforms(entry, vertical = false) {
entry.applyCanvasTransforms(vertical);
}
/**
* Return image data of the multi-canvas
*
* When using a `type` of `'blob'`, this will return a `Promise`.
*
* @param {string} format='image/png' An optional value of a format type.
* @param {number} quality=0.92 An optional value between 0 and 1.
* @param {string} type='dataURL' Either 'dataURL' or 'blob'.
* @return {string|string[]|Promise} When using the default `'dataURL'`
* `type` this returns a single data URL or an array of data URLs,
* one for each canvas. When using the `'blob'` `type` this returns a
* `Promise` that resolves with an array of `Blob` instances, one for each
* canvas.
*/
getImage(format, quality, type) {
if (type === 'blob') {
return Promise.all(
this.canvases.map(entry => {
return entry.getImage(format, quality, type);
})
);
} else if (type === 'dataURL') {
let images = this.canvases.map(entry =>
entry.getImage(format, quality, type)
);
return images.length > 1 ? images : images[0];
}
}
/**
* Render the new progress
*
* @param {number} position X-offset of progress position in pixels
*/
updateProgress(position) {
this.style(this.progressWave, { width: position + 'px' });
}
}