Last active
December 20, 2023 00:08
-
-
Save loretoparisi/c5c2b878ba553de05d5a928c85f89a5f to your computer and use it in GitHub Desktop.
Wavesurfer js setup with timing labels and time formating callback
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Wavesurfer.js minimalistic example with | |
* regions loading, save, plugins, waveform loading from json, waveform zoom, player events, keyboard codes, auto play | |
* @author: Loreto Parisi (loretoparisi at gmail dot com) - mainly, some code and functions adpated from Wavesurfer examples | |
* @disclaimer: code adapted from online examples | |
*/ | |
// JavaScript | |
// Wrap the native DOM audio element play function and handle any autoplay errors | |
Audio.prototype.play = (function(play) { | |
return function() { | |
var audio = this, | |
args = arguments, | |
promise = play.apply(audio, args); | |
if (promise !== undefined) { | |
promise.catch(_ => { | |
// Autoplay was prevented. This is optional, but add a button to start playing. | |
}); | |
} | |
}; | |
})(Audio.prototype.play); | |
/** | |
* Save annotations to localStorage. | |
*/ | |
function saveRegions() { | |
localStorage.regions = JSON.stringify( | |
Object.keys(wavesurfer.regions.list).map(function(id) { | |
var region = wavesurfer.regions.list[id]; | |
return { | |
start: region.start, | |
end: region.end, | |
attributes: region.attributes, | |
data: region.data | |
}; | |
}) | |
); | |
} //saveRegions | |
/** | |
* Load regions from localStorage. | |
*/ | |
function loadRegions(regions) { | |
regions.forEach(function(region) { | |
// number of colors, transparency | |
var randomColor = Math.floor(Math.random()*16777215).toString(16); | |
region.color = randomColor; | |
wavesurfer.addRegion(region); | |
}); | |
} //loadRegions | |
/** | |
* Use formatTimeCallback to style the notch labels as you wish, such | |
* as with more detail as the number of pixels per second increases. | |
* | |
* Here we format as M:SS.frac, with M suppressed for times < 1 minute, | |
* and frac having 0, 1, or 2 digits as the zoom increases. | |
* | |
* Note that if you override the default function, you'll almost | |
* certainly want to override timeInterval, primaryLabelInterval and/or | |
* secondaryLabelInterval so they all work together. | |
* | |
* @param: seconds | |
* @param: pxPerSec | |
*/ | |
function formatTimeCallback(seconds, pxPerSec) { | |
seconds = Number(seconds); | |
var minutes = Math.floor(seconds / 60); | |
seconds = seconds % 60; | |
// fill up seconds with zeroes | |
var secondsStr = Math.round(seconds).toString(); | |
if (pxPerSec >= 25 * 10) { | |
secondsStr = seconds.toFixed(2); | |
} else if (pxPerSec >= 25 * 1) { | |
secondsStr = seconds.toFixed(1); | |
} | |
if (minutes > 0) { | |
if (seconds < 10) { | |
secondsStr = '0' + secondsStr; | |
} | |
return `${minutes}:${secondsStr}`; | |
} | |
return secondsStr; | |
} | |
/** | |
* Use timeInterval to set the period between notches, in seconds, | |
* adding notches as the number of pixels per second increases. | |
* | |
* Note that if you override the default function, you'll almost | |
* certainly want to override formatTimeCallback, primaryLabelInterval | |
* and/or secondaryLabelInterval so they all work together. | |
* | |
* @param: pxPerSec | |
*/ | |
function timeInterval(pxPerSec) { | |
var retval = 1; | |
if (pxPerSec >= 25 * 100) { | |
retval = 0.01; | |
} else if (pxPerSec >= 25 * 40) { | |
retval = 0.025; | |
} else if (pxPerSec >= 25 * 10) { | |
retval = 0.1; | |
} else if (pxPerSec >= 25 * 4) { | |
retval = 0.25; | |
} else if (pxPerSec >= 25) { | |
retval = 1; | |
} else if (pxPerSec * 5 >= 25) { | |
retval = 5; | |
} else if (pxPerSec * 15 >= 25) { | |
retval = 15; | |
} else { | |
retval = Math.ceil(0.5 / pxPerSec) * 60; | |
} | |
return retval; | |
} | |
/** | |
* Return the cadence of notches that get labels in the primary color. | |
* EG, return 2 if every 2nd notch should be labeled, | |
* return 10 if every 10th notch should be labeled, etc. | |
* | |
* Note that if you override the default function, you'll almost | |
* certainly want to override formatTimeCallback, primaryLabelInterval | |
* and/or secondaryLabelInterval so they all work together. | |
* | |
* @param pxPerSec | |
*/ | |
function primaryLabelInterval(pxPerSec) { | |
var retval = 1; | |
if (pxPerSec >= 25 * 100) { | |
retval = 10; | |
} else if (pxPerSec >= 25 * 40) { | |
retval = 4; | |
} else if (pxPerSec >= 25 * 10) { | |
retval = 10; | |
} else if (pxPerSec >= 25 * 4) { | |
retval = 4; | |
} else if (pxPerSec >= 25) { | |
retval = 1; | |
} else if (pxPerSec * 5 >= 25) { | |
retval = 5; | |
} else if (pxPerSec * 15 >= 25) { | |
retval = 15; | |
} else { | |
retval = Math.ceil(0.5 / pxPerSec) * 60; | |
} | |
return retval; | |
} | |
/** | |
* Return the cadence of notches to get labels in the secondary color. | |
* EG, return 2 if every 2nd notch should be labeled, | |
* return 10 if every 10th notch should be labeled, etc. | |
* | |
* Secondary labels are drawn after primary labels, so if | |
* you want to have labels every 10 seconds and another color labels | |
* every 60 seconds, the 60 second labels should be the secondaries. | |
* | |
* Note that if you override the default function, you'll almost | |
* certainly want to override formatTimeCallback, primaryLabelInterval | |
* and/or secondaryLabelInterval so they all work together. | |
* | |
* @param pxPerSec | |
*/ | |
function secondaryLabelInterval(pxPerSec) { | |
// draw one every 10s as an example | |
return Math.floor(10 / timeInterval(pxPerSec)); | |
} | |
// true to activate colormap plugin | |
const colorMap = false; | |
// true to use a waveform from json data points | |
const loadWaveformFromJSON = true; | |
const minPxPerSec = 200; | |
// the audio file uri to be loaded | |
const myAudioFilePath = "" | |
// the uri of the json waveform generator | |
// please note that minPxPerSec must be coherent with dots returned from this waveform | |
const myWaveformGeneratorPath = "" | |
var plugins = [ | |
WaveSurfer.regions.create({ | |
/*regions: [ | |
{ "id": 1, "color": "red", "start": 2.96, "end": 3.5, "data": {}, "attributes": {"label": "stars", "highlight":true}} | |
]*/ | |
}), | |
WaveSurfer.minimap.create({ | |
height: 100, | |
waveColor: '#ddd', | |
progressColor: '#999', | |
cursorColor: '#999' | |
}), | |
WaveSurfer.timeline.create({ | |
container: '#wave-timeline', | |
formatTimeCallback: formatTimeCallback, | |
timeInterval: timeInterval, | |
primaryLabelInterval: primaryLabelInterval, | |
secondaryLabelInterval: secondaryLabelInterval, | |
primaryColor: 'blue', | |
secondaryColor: 'red', | |
primaryFontColor: 'blue', | |
secondaryFontColor: 'red' | |
}) | |
] | |
if (colorMap) { // add spectrogram plugin, running on the main thread it freezes the UI | |
plugins.push(WaveSurfer.spectrogram.create({ | |
container: '#wave-spectrogram', | |
labels: true, | |
colorMap: colorMap | |
})) | |
}; | |
// create waveform | |
wavesurfer = WaveSurfer.create({ | |
container: document.querySelector('#waveform'), | |
waveColor: '#D9DCFF', | |
progressColor: '#4353FF', | |
cursorColor: '#4353FF', | |
normalize: true, | |
//LP: danger higher values will crash the computer | |
pixelRatio: 1, | |
minPxPerSec: minPxPerSec, | |
scrollParent: true, | |
// media playback | |
//backend: 'MediaElement', | |
//// bar visualization | |
barWidth: 3, | |
barRadius: 3, | |
barGap: 1, | |
barHeight: 1, | |
//// bar visualization | |
hideScrollbar: false, | |
cursorWidth: 1, | |
height: 300, | |
plugins: plugins | |
}); | |
// set initial zoom to match slider value | |
wavesurfer.zoom(minPxPerSec); | |
// Zoom slider | |
var slider = document.querySelector('[data-action="zoom"]'); | |
slider.value = wavesurfer.params.minPxPerSec; | |
//slider.min = wavesurfer.params.minPxPerSec; | |
slider.addEventListener('input', function() { | |
console.log("pxPerSec", this.value); | |
//zoom(pxPerSec) – Horizontally zooms the waveform in and out. The parameter is a number of horizontal pixels per second of audio. | |
// It also changes the parameter minPxPerSec and enables the scrollParent option. | |
wavesurfer.zoom(Number(this.value)); | |
}); | |
// events | |
wavesurfer.on('error', function(e) { | |
}); | |
// bind wavesurfer events | |
wavesurfer.on('ready', function() { | |
// load here regions data | |
var data = []; | |
// just adding an example region | |
var elem = { | |
start: 0, | |
end: 5, | |
data: {}, | |
attributes: { | |
label: "my label" | |
}, | |
drag: false, | |
resize: false | |
}; | |
data.push(elem); | |
loadRegions(data); | |
// now save loaded regions to local storage | |
saveRegions(); | |
}); // ready | |
// load waveform from json | |
if (loadWaveformFromJSON) { | |
wavesurfer.util | |
.fetchFile({ | |
responseType: 'json', | |
url: `${myWaveformGeneratorPath}` | |
}) | |
.on('success', function(data) { | |
var points = data.data || data; | |
wavesurfer.load( | |
`${myAudioFilePath}`, | |
points | |
); | |
if (autoPlay) { // autoplay | |
// not working: The AudioContext was not allowed to start. It must be resumed (or created) after a user gesture on the page | |
wavesurfer.seekTo(0); | |
setTimeout(() => wavesurfer.play(), 1000); | |
} else { // seek to load annotations data | |
wavesurfer.seekTo(0); | |
} | |
}); | |
} else { | |
wavesurfer.load(`${myAudioFilePath}`); | |
} | |
// player actions | |
var GLOBAL_ACTIONS = { | |
'play': function() { | |
wavesurfer.playPause(); | |
}, | |
'back': function() { | |
wavesurfer.skipBackward(); | |
}, | |
'forth': function() { | |
wavesurfer.skipForward(); | |
}, | |
'toggle-mute': function() { | |
wavesurfer.toggleMute(); | |
} | |
}; | |
// handle keyboard events | |
document.addEventListener('keydown', function(e) { | |
var map = { | |
32: 'play', // space | |
37: 'back', // left | |
39: 'forth' // right | |
}; | |
var action = map[e.keyCode]; | |
if (action in GLOBAL_ACTIONS) { | |
if (document == e.target || document.body == e.target) { | |
e.preventDefault(); | |
} | |
GLOBAL_ACTIONS[action](e); | |
} | |
}); | |
// bind actions to events | |
[].forEach.call(document.querySelectorAll('[data-action]'), function(el) { | |
el.addEventListener('click', function(e) { | |
var action = e.currentTarget.dataset.action; | |
if (action in GLOBAL_ACTIONS) { | |
e.preventDefault(); | |
GLOBAL_ACTIONS[action](e); | |
} | |
}); | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Hey there :)
Just stumbled upon you snippet here.
Do you have a gist or a pen somewhere, where you can play with your extension?
I'm currently doing the same (adding labels, improving region draggin and maybe I can also add my 2 cents.)