UNPKG

wavesurfer.js

Version:

Interactive navigable audio visualization using Web Audio and Canvas

523 lines (463 loc) 18.1 kB
/* eslint-enable complexity, no-redeclare, no-var, one-var */ import FFT from './fft'; /** * @typedef {Object} SpectrogramPluginParams * @property {string|HTMLElement} container Selector of element or element in * which to render * @property {number} fftSamples=512 Number of samples to fetch to FFT. Must be * a power of 2. * @property {boolean} splitChannels=false Render with separate spectrograms for * the channels of the audio * @property {number} height=fftSamples/2 Height of the spectrogram view in CSS * pixels * @property {boolean} labels Set to true to display frequency labels. * @property {number} noverlap Size of the overlapping window. Must be < * fftSamples. Auto deduced from canvas size by default. * @property {string} windowFunc='hann' The window function to be used. One of * these: `'bartlett'`, `'bartlettHann'`, `'blackman'`, `'cosine'`, `'gauss'`, * `'hamming'`, `'hann'`, `'lanczoz'`, `'rectangular'`, `'triangular'` * @property {?number} alpha Some window functions have this extra value. * (Between 0 and 1) * @property {number} pixelRatio=wavesurfer.params.pixelRatio to control the * size of the spectrogram in relation with its canvas. 1 = Draw on the whole * canvas. 2 = Draw on a quarter (1/2 the length and 1/2 the width) * @property {number} frequencyMin=0 Min frequency to scale spectrogram. * @property {number} frequencyMax=12000 Max frequency to scale spectrogram. * Set this to samplerate/2 to draw whole range of spectrogram. * @property {?boolean} deferInit Set to true to manually call * `initPlugin('spectrogram')` * @property {?number[][]} colorMap A 256 long array of 4-element arrays. * Each entry should contain a float between 0 and 1 and specify * r, g, b, and alpha. */ /** * Render a spectrogram visualisation of the audio. * * @implements {PluginClass} * @extends {Observer} * @example * // es6 * import SpectrogramPlugin from 'wavesurfer.spectrogram.js'; * * // commonjs * var SpectrogramPlugin = require('wavesurfer.spectrogram.js'); * * // if you are using <script> tags * var SpectrogramPlugin = window.WaveSurfer.spectrogram; * * // ... initialising wavesurfer with the plugin * var wavesurfer = WaveSurfer.create({ * // wavesurfer options ... * plugins: [ * SpectrogramPlugin.create({ * // plugin options ... * }) * ] * }); */ export default class SpectrogramPlugin { /** * Spectrogram plugin definition factory * * This function must be used to create a plugin definition which can be * used by wavesurfer to correctly instantiate the plugin. * * @param {SpectrogramPluginParams} params Parameters used to initialise the plugin * @return {PluginDefinition} An object representing the plugin. */ static create(params) { return { name: 'spectrogram', deferInit: params && params.deferInit ? params.deferInit : false, params: params, staticProps: { FFT: FFT }, instance: SpectrogramPlugin }; } constructor(params, ws) { this.params = params; this.wavesurfer = ws; this.util = ws.util; this.frequenciesDataUrl = params.frequenciesDataUrl; this._onScroll = e => { this.updateScroll(e); }; this._onRender = () => { this.render(); }; this._onWrapperClick = e => { this._wrapperClickHandler(e); }; this._onReady = () => { const drawer = (this.drawer = ws.drawer); this.container = 'string' == typeof params.container ? document.querySelector(params.container) : params.container; if (!this.container) { throw Error('No container for WaveSurfer spectrogram'); } if (params.colorMap) { if (params.colorMap.length < 256) { throw new Error('Colormap must contain 256 elements'); } for (let i = 0; i < params.colorMap.length; i++) { const cmEntry = params.colorMap[i]; if (cmEntry.length !== 4) { throw new Error( 'ColorMap entries must contain 4 values' ); } } this.colorMap = params.colorMap; } else { this.colorMap = []; for (let i = 0; i < 256; i++) { const val = (255 - i) / 256; this.colorMap.push([val, val, val, 1]); } } this.width = drawer.width; this.pixelRatio = this.params.pixelRatio || ws.params.pixelRatio; this.fftSamples = this.params.fftSamples || ws.params.fftSamples || 512; this.height = this.params.height || this.fftSamples / 2; this.noverlap = params.noverlap; this.windowFunc = params.windowFunc; this.alpha = params.alpha; this.splitChannels = params.splitChannels; this.channels = this.splitChannels ? ws.backend.buffer.numberOfChannels : 1; // Getting file's original samplerate is difficult(#1248). // So set 12kHz default to render like wavesurfer.js 5.x. this.frequencyMin = params.frequencyMin || 0; this.frequencyMax = params.frequencyMax || 12000; this.createWrapper(); this.createCanvas(); this.render(); drawer.wrapper.addEventListener('scroll', this._onScroll); ws.on('redraw', this._onRender); }; } init() { // Check if wavesurfer is ready if (this.wavesurfer.isReady) { this._onReady(); } else { this.wavesurfer.once('ready', this._onReady); } } destroy() { this.unAll(); this.wavesurfer.un('ready', this._onReady); this.wavesurfer.un('redraw', this._onRender); this.drawer && this.drawer.wrapper.removeEventListener('scroll', this._onScroll); this.wavesurfer = null; this.util = null; this.params = null; if (this.wrapper) { this.wrapper.removeEventListener('click', this._onWrapperClick); this.wrapper.parentNode.removeChild(this.wrapper); this.wrapper = null; } } createWrapper() { const prevSpectrogram = this.container.querySelector('spectrogram'); if (prevSpectrogram) { this.container.removeChild(prevSpectrogram); } const wsParams = this.wavesurfer.params; this.wrapper = document.createElement('spectrogram'); // if labels are active if (this.params.labels) { const labelsEl = (this.labelsEl = document.createElement('canvas')); labelsEl.classList.add('spec-labels'); this.drawer.style(labelsEl, { position: 'absolute', zIndex: 9, height: `${this.height * this.channels}px`, width: `55px` }); this.wrapper.appendChild(labelsEl); this.loadLabels( 'rgba(68,68,68,0.5)', '12px', '10px', '', '#fff', '#f7f7f7', 'center', '#specLabels' ); } this.drawer.style(this.wrapper, { display: 'block', position: 'relative', userSelect: 'none', webkitUserSelect: 'none', height: `${this.height * this.channels}px` }); if (wsParams.fillParent || wsParams.scrollParent) { this.drawer.style(this.wrapper, { width: '100%', overflowX: 'hidden', overflowY: 'hidden' }); } this.container.appendChild(this.wrapper); this.wrapper.addEventListener('click', this._onWrapperClick); } _wrapperClickHandler(event) { event.preventDefault(); const relX = 'offsetX' in event ? event.offsetX : event.layerX; this.fireEvent('click', relX / this.width || 0); } createCanvas() { const canvas = (this.canvas = this.wrapper.appendChild( document.createElement('canvas') )); this.spectrCc = canvas.getContext('2d'); this.util.style(canvas, { position: 'absolute', zIndex: 4 }); } render() { this.updateCanvasStyle(); if (this.frequenciesDataUrl) { this.loadFrequenciesData(this.frequenciesDataUrl); } else { this.getFrequencies(this.drawSpectrogram); } } updateCanvasStyle() { const width = Math.round(this.width / this.pixelRatio) + 'px'; this.canvas.width = this.width; this.canvas.height = this.fftSamples / 2 * this.channels; this.canvas.style.width = width; this.canvas.style.height = this.height + 'px'; } drawSpectrogram(frequenciesData, my) { if (!isNaN(frequenciesData[0][0])) { // data is 1ch [sample, freq] format // to [channel, sample, freq] format frequenciesData = [frequenciesData]; } const spectrCc = my.spectrCc; const height = my.fftSamples / 2; const width = my.width; const freqFrom = my.buffer.sampleRate / 2; const freqMin = my.frequencyMin; const freqMax = my.frequencyMax; if (!spectrCc) { return; } for (let c = 0; c < frequenciesData.length; c++) { // for each channel const pixels = my.resample(frequenciesData[c]); const imageData = new ImageData(width, height); for (let i = 0; i < pixels.length; i++) { for (let j = 0; j < pixels[i].length; j++) { const colorMap = my.colorMap[pixels[i][j]]; const redIndex = ((height - j) * width + i) * 4; imageData.data[redIndex] = colorMap[0] * 255; imageData.data[redIndex + 1] = colorMap[1] * 255; imageData.data[redIndex + 2] = colorMap[2] * 255; imageData.data[redIndex + 3] = colorMap[3] * 255; } } // scale and stack spectrograms createImageBitmap(imageData).then(renderer => spectrCc.drawImage(renderer, 0, height * (1 - freqMax / freqFrom), // source x, y width, height * (freqMax - freqMin) / freqFrom, // source width, height 0, height * c, // destination x, y width, height // destination width, height ) ); } } getFrequencies(callback) { const fftSamples = this.fftSamples; const buffer = (this.buffer = this.wavesurfer.backend.buffer); const channels = this.channels; if (!buffer) { this.fireEvent('error', 'Web Audio buffer is not available'); return; } // This may differ from file samplerate. Browser resamples audio. const sampleRate = buffer.sampleRate; const frequencies = []; let noverlap = this.noverlap; if (!noverlap) { const uniqueSamplesPerPx = buffer.length / this.canvas.width; noverlap = Math.max(0, Math.round(fftSamples - uniqueSamplesPerPx)); } const fft = new FFT( fftSamples, sampleRate, this.windowFunc, this.alpha ); for (let c = 0; c < channels; c++) { // for each channel const channelData = buffer.getChannelData(c); const channelFreq = []; let currentOffset = 0; while (currentOffset + fftSamples < channelData.length) { const segment = channelData.slice( currentOffset, currentOffset + fftSamples ); const spectrum = fft.calculateSpectrum(segment); const array = new Uint8Array(fftSamples / 2); let j; for (j = 0; j < fftSamples / 2; j++) { array[j] = Math.max(-255, Math.log10(spectrum[j]) * 45); } channelFreq.push(array); // channelFreq: [sample, freq] currentOffset += fftSamples - noverlap; } frequencies.push(channelFreq); // frequencies: [channel, sample, freq] } callback(frequencies, this); } loadFrequenciesData(url) { const request = this.util.fetchFile({ url: url }); request.on('success', data => this.drawSpectrogram(JSON.parse(data), this) ); request.on('error', e => this.fireEvent('error', e)); return request; } freqType(freq) { return freq >= 1000 ? (freq / 1000).toFixed(1) : Math.round(freq); } unitType(freq) { return freq >= 1000 ? 'KHz' : 'Hz'; } loadLabels( bgFill, fontSizeFreq, fontSizeUnit, fontType, textColorFreq, textColorUnit, textAlign, container ) { const frequenciesHeight = this.height; bgFill = bgFill || 'rgba(68,68,68,0)'; fontSizeFreq = fontSizeFreq || '12px'; fontSizeUnit = fontSizeUnit || '10px'; fontType = fontType || 'Helvetica'; textColorFreq = textColorFreq || '#fff'; textColorUnit = textColorUnit || '#fff'; textAlign = textAlign || 'center'; container = container || '#specLabels'; const bgWidth = 55; const getMaxY = frequenciesHeight || 512; const labelIndex = 5 * (getMaxY / 256); const freqStart = this.frequencyMin; const step = (this.frequencyMax - freqStart) / labelIndex; // prepare canvas element for labels const ctx = this.labelsEl.getContext('2d'); const dispScale = window.devicePixelRatio; this.labelsEl.height = this.height * this.channels * dispScale; this.labelsEl.width = bgWidth * dispScale; ctx.scale(dispScale, dispScale); if (!ctx) { return; } for (let c = 0; c < this.channels; c++) { // for each channel // fill background ctx.fillStyle = bgFill; ctx.fillRect(0, c * getMaxY, bgWidth, (1 + c) * getMaxY); ctx.fill(); let i; // render labels for (i = 0; i <= labelIndex; i++) { ctx.textAlign = textAlign; ctx.textBaseline = 'middle'; const freq = freqStart + step * i; const label = this.freqType(freq); const units = this.unitType(freq); const yLabelOffset = 2; const x = 16; let y; if (i == 0) { y = (1 + c) * getMaxY + i - 10; // unit label ctx.fillStyle = textColorUnit; ctx.font = fontSizeUnit + ' ' + fontType; ctx.fillText(units, x + 24, y); // freq label ctx.fillStyle = textColorFreq; ctx.font = fontSizeFreq + ' ' + fontType; ctx.fillText(label, x, y); } else { y = (1 + c) * getMaxY - i * 50 + yLabelOffset; // unit label ctx.fillStyle = textColorUnit; ctx.font = fontSizeUnit + ' ' + fontType; ctx.fillText(units, x + 24, y); // freq label ctx.fillStyle = textColorFreq; ctx.font = fontSizeFreq + ' ' + fontType; ctx.fillText(label, x, y); } } } } updateScroll(e) { if (this.wrapper) { this.wrapper.scrollLeft = e.target.scrollLeft; } } resample(oldMatrix) { const columnsNumber = this.width; const newMatrix = []; const oldPiece = 1 / oldMatrix.length; const newPiece = 1 / columnsNumber; let i; for (i = 0; i < columnsNumber; i++) { const column = new Array(oldMatrix[0].length); let j; for (j = 0; j < oldMatrix.length; j++) { const oldStart = j * oldPiece; const oldEnd = oldStart + oldPiece; const newStart = i * newPiece; const newEnd = newStart + newPiece; const overlap = oldEnd <= newStart || newEnd <= oldStart ? 0 : Math.min( Math.max(oldEnd, newStart), Math.max(newEnd, oldStart) ) - Math.max( Math.min(oldEnd, newStart), Math.min(newEnd, oldStart) ); let k; /* eslint-disable max-depth */ if (overlap > 0) { for (k = 0; k < oldMatrix[0].length; k++) { if (column[k] == null) { column[k] = 0; } column[k] += (overlap / newPiece) * oldMatrix[j][k]; } } /* eslint-enable max-depth */ } const intColumn = new Uint8Array(oldMatrix[0].length); let m; for (m = 0; m < oldMatrix[0].length; m++) { intColumn[m] = column[m]; } newMatrix.push(intColumn); } return newMatrix; } }