wavesurfer.js
Version:
Interactive navigable audio visualization using Web Audio and Canvas
308 lines (274 loc) • 10.1 kB
JavaScript
/**
* @typedef {Object} ElanPluginParams
* @property {string|HTMLElement} container CSS selector or HTML element where
* the ELAN information should be rendered.
* @property {string} url The location of ELAN XML data
* @property {?boolean} deferInit Set to true to manually call
* @property {?Object} tiers If set only shows the data tiers with the `TIER_ID`
* in this map.
*/
/**
* Downloads and renders ELAN audio transcription documents alongside the
* waveform.
*
* @implements {PluginClass}
* @extends {Observer}
* @example
* // es6
* import ElanPlugin from 'wavesurfer.elan.js';
*
* // commonjs
* var ElanPlugin = require('wavesurfer.elan.js');
*
* // if you are using <script> tags
* var ElanPlugin = window.WaveSurfer.elan;
*
* // ... initialising wavesurfer with the plugin
* var wavesurfer = WaveSurfer.create({
* // wavesurfer options ...
* plugins: [
* ElanPlugin.create({
* // plugin options ...
* })
* ]
* });
*/
export default class ElanPlugin {
/**
* Elan 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 {ElanPluginParams} params parameters use to initialise the plugin
* @return {PluginDefinition} an object representing the plugin
*/
static create(params) {
return {
name: 'elan',
deferInit: params && params.deferInit ? params.deferInit : false,
params: params,
instance: ElanPlugin
};
}
Types = {
ALIGNABLE_ANNOTATION: 'ALIGNABLE_ANNOTATION',
REF_ANNOTATION: 'REF_ANNOTATION'
};
constructor(params, ws) {
this.data = null;
this.params = params;
this.container =
'string' == typeof params.container
? document.querySelector(params.container)
: params.container;
if (!this.container) {
throw Error('No container for ELAN');
}
}
init() {
this.bindClick();
if (this.params.url) {
this.load(this.params.url);
}
}
destroy() {
this.container.removeEventListener('click', this._onClick);
this.container.removeChild(this.table);
}
load(url) {
this.loadXML(url, xml => {
this.data = this.parseElan(xml);
this.render();
this.fireEvent('ready', this.data);
});
}
loadXML(url, callback) {
const xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.responseType = 'document';
xhr.send();
xhr.addEventListener('load', e => {
callback && callback(e.target.responseXML);
});
}
parseElan(xml) {
const _forEach = Array.prototype.forEach;
const _map = Array.prototype.map;
const data = {
media: {},
timeOrder: {},
tiers: [],
annotations: {},
alignableAnnotations: []
};
const header = xml.querySelector('HEADER');
const inMilliseconds =
header.getAttribute('TIME_UNITS') == 'milliseconds';
const media = header.querySelector('MEDIA_DESCRIPTOR');
data.media.url = media.getAttribute('MEDIA_URL');
data.media.type = media.getAttribute('MIME_TYPE');
const timeSlots = xml.querySelectorAll('TIME_ORDER TIME_SLOT');
const timeOrder = {};
_forEach.call(timeSlots, slot => {
let value = parseFloat(slot.getAttribute('TIME_VALUE'));
// If in milliseconds, convert to seconds with rounding
if (inMilliseconds) {
value = Math.round(value * 1e2) / 1e5;
}
timeOrder[slot.getAttribute('TIME_SLOT_ID')] = value;
});
data.tiers = _map.call(xml.querySelectorAll('TIER'), tier => ({
id: tier.getAttribute('TIER_ID'),
linguisticTypeRef: tier.getAttribute('LINGUISTIC_TYPE_REF'),
defaultLocale: tier.getAttribute('DEFAULT_LOCALE'),
annotations: _map.call(
tier.querySelectorAll('REF_ANNOTATION, ALIGNABLE_ANNOTATION'),
node => {
const annot = {
type: node.nodeName,
id: node.getAttribute('ANNOTATION_ID'),
ref: node.getAttribute('ANNOTATION_REF'),
value: node
.querySelector('ANNOTATION_VALUE')
.textContent.trim()
};
if (this.Types.ALIGNABLE_ANNOTATION == annot.type) {
// Add start & end to alignable annotation
annot.start =
timeOrder[node.getAttribute('TIME_SLOT_REF1')];
annot.end =
timeOrder[node.getAttribute('TIME_SLOT_REF2')];
// Add to the list of alignable annotations
data.alignableAnnotations.push(annot);
}
// Additionally, put into the flat map of all annotations
data.annotations[annot.id] = annot;
return annot;
}
)
}));
// Create JavaScript references between annotations
data.tiers.forEach(tier => {
tier.annotations.forEach(annot => {
if (null != annot.ref) {
annot.reference = data.annotations[annot.ref];
}
});
});
// Sort alignable annotations by start & end
data.alignableAnnotations.sort((a, b) => {
let d = a.start - b.start;
if (d == 0) {
d = b.end - a.end;
}
return d;
});
data.length = data.alignableAnnotations.length;
return data;
}
render() {
// apply tiers filter
let tiers = this.data.tiers;
if (this.params.tiers) {
tiers = tiers.filter(tier => tier.id in this.params.tiers);
}
// denormalize references to alignable annotations
const backRefs = {};
let indeces = {};
tiers.forEach((tier, index) => {
tier.annotations.forEach(annot => {
if (
annot.reference &&
annot.reference.type == this.Types.ALIGNABLE_ANNOTATION
) {
if (!(annot.reference.id in backRefs)) {
backRefs[annot.ref] = {};
}
backRefs[annot.ref][index] = annot;
indeces[index] = true;
}
});
});
indeces = Object.keys(indeces).sort();
this.renderedAlignable = this.data.alignableAnnotations.filter(
alignable => backRefs[alignable.id]
);
// table
const table = (this.table = document.createElement('table'));
table.className = 'wavesurfer-annotations';
// head
const thead = document.createElement('thead');
const headRow = document.createElement('tr');
thead.appendChild(headRow);
table.appendChild(thead);
const th = document.createElement('th');
th.textContent = 'Time';
th.className = 'wavesurfer-time';
headRow.appendChild(th);
indeces.forEach(index => {
const tier = tiers[index];
const th = document.createElement('th');
th.className = 'wavesurfer-tier-' + tier.id;
th.textContent = tier.id;
if (this.params.tiers) { th.style.width = this.params.tiers[tier.id]; }
headRow.appendChild(th);
});
// body
const tbody = document.createElement('tbody');
table.appendChild(tbody);
this.renderedAlignable.forEach(alignable => {
const row = document.createElement('tr');
row.id = 'wavesurfer-alignable-' + alignable.id;
tbody.appendChild(row);
const td = document.createElement('td');
td.className = 'wavesurfer-time';
td.textContent =
alignable.start.toFixed(1) + '–' + alignable.end.toFixed(1);
row.appendChild(td);
const backRef = backRefs[alignable.id];
indeces.forEach(index => {
const tier = tiers[index];
const td = document.createElement('td');
const annotation = backRef[index];
if (annotation) {
td.id = 'wavesurfer-annotation-' + annotation.id;
td.dataset.ref = alignable.id;
td.dataset.start = alignable.start;
td.dataset.end = alignable.end;
td.textContent = annotation.value;
}
td.className = 'wavesurfer-tier-' + tier.id;
row.appendChild(td);
});
});
this.container.innerHTML = '';
this.container.appendChild(table);
}
bindClick() {
this._onClick = e => {
const ref = e.target.dataset.ref;
if (null != ref) {
const annot = this.data.annotations[ref];
if (annot) {
this.fireEvent('select', annot.start, annot.end);
}
}
};
this.container.addEventListener('click', this._onClick);
}
getRenderedAnnotation(time) {
let result;
this.renderedAlignable.some(annotation => {
if (annotation.start <= time && annotation.end >= time) {
result = annotation;
return true;
}
return false;
});
return result;
}
getAnnotationNode(annotation) {
return document.getElementById('wavesurfer-alignable-' + annotation.id);
}
}