├── .npmignore ├── peaks.png ├── demo ├── favicon.ico ├── TOL_6min_720p_download.dat ├── TOL_6min_720p_download.mp3 ├── TOL_6min_720p_download.ogg ├── overview-waveform.html ├── zoomable-waveform.html ├── set-source.html └── multi-channel.html ├── test_data ├── sample.dat ├── sample.mp3 ├── sample.ogg ├── 07023003.mp3 └── 07023003-2channel.dat ├── test ├── test_img │ ├── zoom_0_offset.png │ └── zoom_10000_offset.png ├── .eslintrc.js └── unit │ ├── setup.js │ ├── waveform-segments-spec.js │ ├── api-view-apec.js │ ├── point-spec.js │ ├── api-zoom-spec.js │ ├── api-player-spec.js │ ├── segment-spec.js │ ├── utils-spec.js │ ├── waveform-builder-spec.js │ └── api-views-spec.js ├── .gitignore ├── bower.json ├── src └── main │ ├── views │ ├── zooms │ │ ├── static.js │ │ └── animated.js │ ├── waveform.timecontroller.js │ ├── view-controller.js │ ├── waveform.zoomcontroller.js │ ├── waveform-shape.js │ ├── helpers │ │ └── mousedraghandler.js │ ├── playhead-layer.js │ ├── points-layer.js │ └── waveform.overview.js │ ├── cues │ ├── cue.js │ └── cue-emitter.js │ ├── player │ ├── player.keyboard.js │ └── player.js │ ├── markers │ ├── point.js │ ├── segment.js │ ├── waveform.points.js │ └── waveform.segments.js │ └── waveform │ ├── waveform.axis.js │ ├── waveform.utils.js │ └── waveform.mixins.js ├── .travis.yml ├── CONTRIBUTING.md ├── .eslintrc.js ├── package.json ├── karma.conf.js ├── peaks.js.d.ts └── COPYING /.npmignore: -------------------------------------------------------------------------------- 1 | bower_components 2 | node_modules 3 | 4 | test 5 | test_data 6 | -------------------------------------------------------------------------------- /peaks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrevorHinesley/peaks.js/master/peaks.png -------------------------------------------------------------------------------- /demo/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrevorHinesley/peaks.js/master/demo/favicon.ico -------------------------------------------------------------------------------- /test_data/sample.dat: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrevorHinesley/peaks.js/master/test_data/sample.dat -------------------------------------------------------------------------------- /test_data/sample.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrevorHinesley/peaks.js/master/test_data/sample.mp3 -------------------------------------------------------------------------------- /test_data/sample.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrevorHinesley/peaks.js/master/test_data/sample.ogg -------------------------------------------------------------------------------- /test_data/07023003.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrevorHinesley/peaks.js/master/test_data/07023003.mp3 -------------------------------------------------------------------------------- /demo/TOL_6min_720p_download.dat: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrevorHinesley/peaks.js/master/demo/TOL_6min_720p_download.dat -------------------------------------------------------------------------------- /demo/TOL_6min_720p_download.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrevorHinesley/peaks.js/master/demo/TOL_6min_720p_download.mp3 -------------------------------------------------------------------------------- /demo/TOL_6min_720p_download.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrevorHinesley/peaks.js/master/demo/TOL_6min_720p_download.ogg -------------------------------------------------------------------------------- /test/test_img/zoom_0_offset.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrevorHinesley/peaks.js/master/test/test_img/zoom_0_offset.png -------------------------------------------------------------------------------- /test_data/07023003-2channel.dat: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrevorHinesley/peaks.js/master/test_data/07023003-2channel.dat -------------------------------------------------------------------------------- /test/test_img/zoom_10000_offset.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrevorHinesley/peaks.js/master/test/test_img/zoom_10000_offset.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bower_components 2 | demo/07023003.mp3 3 | demo/07023003-2channel.dat 4 | demo/peaks.js 5 | demo/sample.mp3 6 | docs 7 | node_modules 8 | peaks.js 9 | peaks.js.map 10 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "peaks.js", 3 | "main": "src/main.js", 4 | "ignore": [ 5 | ".eslintrc", 6 | "**/*.txt", 7 | "**/.*", 8 | "node_modules", 9 | "bower_components", 10 | "test", 11 | "test_data" 12 | ], 13 | "dependencies": { 14 | "requirejs": "~2.1.14" 15 | }, 16 | "devDependencies": {} 17 | } 18 | -------------------------------------------------------------------------------- /test/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "browser": true, 4 | "mocha": true, 5 | "node": true, 6 | }, 7 | "extends": "../.eslintrc.js", 8 | "globals": { 9 | "expect": false, 10 | "sinon": false, 11 | "peaks": false 12 | }, 13 | "rules": { 14 | "brace-style": ["error", "stroustrup", { "allowSingleLine": true }], 15 | // Disable no-unused-expressions for chai expectations 16 | "no-unused-expressions": "off", 17 | "newline-after-var": "off", 18 | "max-len": "off" 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /test/unit/setup.js: -------------------------------------------------------------------------------- 1 | beforeEach(function(done) { 2 | var container = document.createElement('div'); 3 | container.id = 'container'; 4 | container.width = '1000px'; 5 | document.body.appendChild(container); 6 | 7 | var mediaElement = document.createElement('audio'); 8 | mediaElement.id = 'media'; 9 | mediaElement.src = '/base/test_data/sample.mp3'; 10 | document.body.appendChild(mediaElement); 11 | 12 | setTimeout(done, 0); 13 | }); 14 | 15 | afterEach(function() { 16 | var container = document.getElementById('container'); 17 | 18 | if (container) { 19 | document.body.removeChild(container); 20 | } 21 | 22 | var mediaElement = document.getElementById('media'); 23 | 24 | if (mediaElement) { 25 | document.body.removeChild(mediaElement); 26 | } 27 | }); 28 | -------------------------------------------------------------------------------- /src/main/views/zooms/static.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 3 | * 4 | * Defines a zoom view adapter with no animations. 5 | * 6 | * @module peaks/views/zooms/static 7 | */ 8 | 9 | define(['peaks/waveform/waveform.utils'], function(Utils) { 10 | 'use strict'; 11 | 12 | return { 13 | create: function(currentScale, previousScale, view) { 14 | return { 15 | start: function(relativePosition) { 16 | // This function is called after data is rescaled to currentScale 17 | // from previousScale. 18 | 19 | view.segmentLayer.draw(); 20 | view.pointLayer.draw(); 21 | 22 | var time = view.peaks.player.getCurrentTime(); 23 | var index = view.timeToPixels(time); 24 | 25 | view.seekFrame(index, relativePosition); 26 | } 27 | }; 28 | } 29 | }; 30 | }); 31 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '8' 4 | - '10' 5 | - 'node' 6 | sudo: false 7 | cache: 8 | directories: 9 | - node_modules 10 | env: 11 | global: 12 | - secure: Bg08bmtZxlt18xVd7IT3Ft8rC8Cd3iT2txSqoFKaUERu2FzlaPvi+DS5K0SeOqAB9LfyRST/Msdg+jmPEvQ/0VXa2lfhqf31YSrXStnd9h1Owxr53KPV3O6FX1mfH8RlmHVDosKUR6mJeiush4MSEFPCmB1VBTwpyRcbBt3sbJ0= 13 | - secure: M4PJdQwYF0dWq+/bMhz4WTRhiKnDuuvgwLImzGQQ0I5zEz6yQofUVmXsAdYkqTvhr8klO+XQktzq62wsWADIe1eb5Vlym87rZweA1MmhzhpfSgYSwQ9IdpVXRbqFVB7HbqrGTHqOSfGOu27e8z4RNg8h3EXCiTnN1qHyY0N5vrI= 14 | script: xvfb-run npm test -- --browsers ChromeHeadless --single-run 15 | deploy: 16 | provider: npm 17 | email: irfs@bbc.co.uk 18 | skip_cleanup: true 19 | api_key: 20 | secure: cSuf3iiNYM2jSjrk7xNxVLuZn+7S78jktze5MyFdK0rchgOessLY0yIDF9MZbhVbCXGgVbygp+dP5IMHPIDFCqC77WyaWCKdRfretb//RoiRAkEL9B6j8kJ//Sl7LPZ5yYpzotC2tF8zDrw0cucz1sXDTRN9x0C1do2O83hS/qk= 21 | on: 22 | tags: true 23 | repo: bbc/peaks.js 24 | -------------------------------------------------------------------------------- /src/main/cues/cue.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 3 | * @module peaks/cues/cue 4 | */ 5 | 6 | define(function() { 7 | 'use strict'; 8 | 9 | /** 10 | * A cue represents an event to be triggered at some point on the media 11 | * timeline. 12 | * 13 | * @class 14 | * @alias Cue 15 | * 16 | * @param {Number} time Cue time, in seconds. 17 | * @param {Number} type Cue mark type, either Cue.POINT, 18 | * Cue.SEGMENT_START, or Cue.SEGMENT_END. 19 | * @param {String} id The id of the {@link Point} or {@link Segment}. 20 | */ 21 | 22 | function Cue(time, type, id) { 23 | this.time = time; 24 | this.type = type; 25 | this.id = id; 26 | } 27 | 28 | /** 29 | * @constant 30 | * @type {Number} 31 | */ 32 | 33 | Cue.POINT = 0; 34 | Cue.SEGMENT_START = 1; 35 | Cue.SEGMENT_END = 2; 36 | 37 | /** 38 | * Callback function for use with Array.prototype.sort(). 39 | * 40 | * @static 41 | * @param {Cue} a 42 | * @param {Cue} b 43 | * @return {Number} 44 | */ 45 | 46 | Cue.sorter = function(a, b) { 47 | return a.time - b.time; 48 | }; 49 | 50 | return Cue; 51 | }); 52 | -------------------------------------------------------------------------------- /src/main/views/waveform.timecontroller.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 3 | * 4 | * Defines the {@link TimeController} class. 5 | * 6 | * @module peaks/views/waveform.timecontroller.js 7 | */ 8 | 9 | define([], function() { 10 | 'use strict'; 11 | 12 | /** 13 | * Creates an object to get/set the playback position. This interface is 14 | * deprecated, use the {@link Player} interface instead. 15 | * 16 | * @class 17 | * @alias TimeController 18 | * 19 | * @param {Peaks} peaks 20 | */ 21 | 22 | function TimeController(peaks) { 23 | this._peaks = peaks; 24 | } 25 | 26 | TimeController.prototype.setCurrentTime = function(time) { 27 | // eslint-disable-next-line max-len 28 | this._peaks.options.deprecationLogger('peaks.time.setCurrentTime(): this function is deprecated. Call peaks.player.seek() instead'); 29 | return this._peaks.player.seek(time); 30 | }; 31 | 32 | TimeController.prototype.getCurrentTime = function() { 33 | // eslint-disable-next-line max-len 34 | this._peaks.options.deprecationLogger('peaks.time.getCurrentTime(): this function is deprecated. Call peaks.player.getCurrentTime() instead'); 35 | return this._peaks.player.getCurrentTime(); 36 | }; 37 | 38 | return TimeController; 39 | }); 40 | -------------------------------------------------------------------------------- /test/unit/waveform-segments-spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('./setup'); 4 | 5 | var Peaks = require('../../src/main'); 6 | 7 | describe('SegmentsLayer', function() { 8 | var p; 9 | 10 | beforeEach(function(done) { 11 | p = Peaks.init({ 12 | container: document.getElementById('container'), 13 | mediaElement: document.getElementById('media'), 14 | dataUri: { 15 | json: 'base/test_data/sample.json' 16 | }, 17 | keyboard: true, 18 | height: 240 19 | }); 20 | 21 | p.on('peaks.ready', done); 22 | }); 23 | 24 | afterEach(function() { 25 | if (p) { 26 | p.destroy(); 27 | } 28 | }); 29 | 30 | describe('segments.add', function() { 31 | it('should redraw the view after adding a segment that is visible', function() { 32 | var zoomview = p.views.getView('zoomview'); 33 | 34 | expect(zoomview).to.be.ok; 35 | 36 | var spy = sinon.spy(zoomview._segmentsLayer._layer, 'draw'); 37 | 38 | p.segments.add({ startTime: 0, endTime: 10, editable: true, id: 'segment1' }); 39 | 40 | expect(spy.callCount).to.equal(1); 41 | }); 42 | 43 | it('should not redraw the view after adding a segment that is not visible', function() { 44 | var zoomview = p.views.getView('zoomview'); 45 | 46 | expect(zoomview).to.be.ok; 47 | 48 | var spy = sinon.spy(zoomview._segmentsLayer._layer, 'draw'); 49 | 50 | p.segments.add({ startTime: 28, endTime: 32, editable: true, id: 'segment2' }); 51 | 52 | expect(spy.callCount).to.equal(0); 53 | }); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /test/unit/api-view-apec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('./setup'); 4 | 5 | var Peaks = require('../../src/main'); 6 | 7 | describe('WaveformView', function() { 8 | var p; 9 | 10 | beforeEach(function(done) { 11 | p = Peaks.init({ 12 | container: document.getElementById('container'), 13 | mediaElement: document.getElementById('media'), 14 | dataUri: 'base/test_data/sample.json' 15 | }); 16 | 17 | p.on('peaks.ready', done); 18 | }); 19 | 20 | afterEach(function() { 21 | if (p) { 22 | p.destroy(); 23 | p = null; 24 | } 25 | }); 26 | 27 | describe('setAmplitudeScale', function() { 28 | ['zoomview', 'overview'].forEach(function(viewName) { 29 | describe(viewName, function() { 30 | it('should set the amplitude scale to default', function() { 31 | var view = p.views.getView(viewName); 32 | 33 | expect(function() { 34 | view.setAmplitudeScale(1.0); 35 | }).to.not.throw; 36 | }); 37 | 38 | it('should throw if no scale is given', function() { 39 | var view = p.views.getView(viewName); 40 | 41 | expect(function() { 42 | view.setAmplitudeScale(); 43 | }).to.throw(/Scale must be a valid number/); 44 | }); 45 | 46 | it('should throw if an invalid scale is given', function() { 47 | var view = p.views.getView(viewName); 48 | 49 | expect(function() { 50 | view.setAmplitudeScale('test'); 51 | }).to.throw(/Scale must be a valid number/); 52 | }); 53 | 54 | it('should throw if an invalid number is given', function() { 55 | var view = p.views.getView(viewName); 56 | 57 | expect(function() { 58 | view.setAmplitudeScale(Infinity); 59 | }).to.throw(/Scale must be a valid number/); 60 | }); 61 | }); 62 | }); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /test/unit/point-spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('./setup'); 4 | 5 | var Peaks = require('../../src/main'); 6 | var Point = require('../../src/main/markers/point'); 7 | 8 | describe('Point', function() { 9 | describe('update()', function() { 10 | var p; 11 | 12 | beforeEach(function(done) { 13 | p = Peaks.init({ 14 | container: document.getElementById('container'), 15 | mediaElement: document.getElementById('media'), 16 | dataUri: { arraybuffer: 'base/test_data/sample.dat' } 17 | }); 18 | 19 | p.on('peaks.ready', done); 20 | }); 21 | 22 | afterEach(function() { 23 | if (p) { 24 | p.destroy(); 25 | } 26 | }); 27 | 28 | it('should be possible to update all properties programatically', function() { 29 | p.points.add({ time: 10, editable: true, color: '#ff0000', labelText: 'A point' }); 30 | 31 | var newLabelText = 'new label text'; 32 | var newTime = 12; 33 | var point = p.points.getPoints()[0]; 34 | 35 | point.update({ 36 | time: newTime, 37 | labelText: newLabelText 38 | }); 39 | 40 | expect(point.time).to.equal(newTime); 41 | expect(point.labelText).to.equal(newLabelText); 42 | }); 43 | 44 | it('should not allow invalid updates', function() { 45 | p.points.add({ time: 10, editable: true, color: '#ff0000', labelText: 'A point' }); 46 | 47 | var point = p.points.getPoints()[0]; 48 | 49 | expect(function() { 50 | point.update({ time: NaN }); 51 | }).to.throw(TypeError); 52 | 53 | expect(function() { 54 | point.update({ time: -10 }); 55 | }).to.throw(TypeError); 56 | 57 | point.update({ labelText: undefined }); 58 | expect(point.labelText).to.equal(''); 59 | }); 60 | }); 61 | 62 | describe('isVisible', function() { 63 | it('should return false if point is before visible range', function() { 64 | var point = new Point({}, 'point.1', 9.0); 65 | 66 | expect(point.isVisible(10.0, 20.0)).to.equal(false); 67 | }); 68 | 69 | it('should return false if point is after visible range', function() { 70 | var point = new Point({}, 'point.1', 20.0); 71 | 72 | expect(point.isVisible(10.0, 20.0)).to.equal(false); 73 | }); 74 | 75 | it('should return true if point is within visible range', function() { 76 | var point = new Point({}, 'point.1', 10.0); 77 | 78 | expect(point.isVisible(10.0, 20.0)).to.equal(true); 79 | }); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /src/main/player/player.keyboard.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 3 | * 4 | * Defines the {@link KeyboardHandler} class. 5 | * 6 | * @module peaks/player/player.keyboard 7 | */ 8 | 9 | define([], function() { 10 | 'use strict'; 11 | 12 | var nodes = ['OBJECT', 'TEXTAREA', 'INPUT', 'SELECT', 'OPTION']; 13 | 14 | var SPACE = 32, 15 | TAB = 9, 16 | LEFT_ARROW = 37, 17 | RIGHT_ARROW = 39; 18 | 19 | var keys = [SPACE, TAB, LEFT_ARROW, RIGHT_ARROW]; 20 | 21 | /** 22 | * Configures keyboard event handling. 23 | * 24 | * @class 25 | * @alias KeyboardHandler 26 | * 27 | * @param {EventEmitter} eventEmitter 28 | */ 29 | function KeyboardHandler(eventEmitter) { 30 | this.eventEmitter = eventEmitter; 31 | 32 | document.addEventListener('keydown', this.handleKeyEvent.bind(this)); 33 | document.addEventListener('keypress', this.handleKeyEvent.bind(this)); 34 | document.addEventListener('keyup', this.handleKeyEvent.bind(this)); 35 | } 36 | 37 | /** 38 | * Keyboard event handler function. 39 | * 40 | * @note Arrow keys only triggered on keydown, not keypress. 41 | * 42 | * @param {KeyboardEvent} event 43 | * @private 44 | */ 45 | 46 | KeyboardHandler.prototype.handleKeyEvent = function handleKeyEvent(event) { 47 | if (nodes.indexOf(event.target.nodeName) === -1) { 48 | if (keys.indexOf(event.type) > -1) { 49 | event.preventDefault(); 50 | } 51 | 52 | if (event.type === 'keydown' || event.type === 'keypress') { 53 | switch (event.keyCode) { 54 | case SPACE: 55 | this.eventEmitter.emit('keyboard.space'); 56 | break; 57 | 58 | case TAB: 59 | this.eventEmitter.emit('keyboard.tab'); 60 | break; 61 | } 62 | } 63 | else if (event.type === 'keyup') { 64 | switch (event.keyCode) { 65 | case LEFT_ARROW: 66 | if (event.shiftKey) { 67 | this.eventEmitter.emit('keyboard.shift_left'); 68 | } 69 | else { 70 | this.eventEmitter.emit('keyboard.left'); 71 | } 72 | break; 73 | 74 | case RIGHT_ARROW: 75 | if (event.shiftKey) { 76 | this.eventEmitter.emit('keyboard.shift_right'); 77 | } 78 | else { 79 | this.eventEmitter.emit('keyboard.right'); 80 | } 81 | break; 82 | } 83 | } 84 | } 85 | }; 86 | 87 | return KeyboardHandler; 88 | }); 89 | -------------------------------------------------------------------------------- /test/unit/api-zoom-spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('./setup'); 4 | 5 | var Peaks = require('../../src/main'); 6 | 7 | describe('Peaks.zoom', function() { 8 | var p; 9 | 10 | beforeEach(function(done) { 11 | p = Peaks.init({ 12 | container: document.getElementById('container'), 13 | mediaElement: document.getElementById('media'), 14 | dataUri: { 15 | json: 'base/test_data/sample.json' 16 | }, 17 | zoomLevels: [512, 1024] 18 | }); 19 | 20 | p.on('peaks.ready', done); 21 | }); 22 | 23 | afterEach(function() { 24 | if (p) { 25 | p.destroy(); 26 | } 27 | }); 28 | 29 | describe('getZoom', function() { 30 | it('should return the initial zoom level index', function() { 31 | expect(p.zoom.getZoom()).to.equal(0); 32 | }); 33 | }); 34 | 35 | describe('setZoom', function() { 36 | it('should update the zoom level index', function() { 37 | p.zoom.setZoom(1); 38 | 39 | expect(p.zoom.getZoom()).to.equal(1); 40 | }); 41 | 42 | it('should emit a zoom.update event with the new zoom level index', function() { 43 | var spy = sinon.spy(); 44 | 45 | p.on('zoom.update', spy); 46 | p.zoom.setZoom(1); 47 | 48 | expect(spy).to.have.been.calledWith(1024, 512); 49 | }); 50 | 51 | it('should limit the zoom level index value to the minimum valid index', function() { 52 | p.zoom.setZoom(-1); 53 | 54 | expect(p.zoom.getZoom()).to.equal(0); 55 | }); 56 | 57 | it('should limit the zoom level index to the maximum valid index', function() { 58 | p.zoom.setZoom(2); 59 | 60 | expect(p.zoom.getZoom()).to.equal(1); 61 | }); 62 | 63 | it('should not throw an exception if an existing zoom level does not have sufficient data', function() { 64 | expect(function() { 65 | p.zoom.setZoom(3); 66 | }).not.to.throw(); 67 | }); 68 | }); 69 | 70 | describe('zoomOut', function() { 71 | it('should call setZoom with a bigger zoom level', function() { 72 | var spy = sinon.spy(); 73 | 74 | p.on('zoom.update', spy); 75 | p.zoom.zoomOut(); 76 | 77 | expect(spy).to.have.been.calledWith(1024, 512); 78 | }); 79 | }); 80 | 81 | describe('zoomIn', function() { 82 | it('should call setZoom with a smaller zoom level', function() { 83 | p.zoom.setZoom(1); 84 | 85 | var spy = sinon.spy(); 86 | 87 | p.on('zoom.update', spy); 88 | p.zoom.zoomIn(); 89 | 90 | expect(spy).to.have.been.calledWith(512, 1024); 91 | }); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /src/main/views/view-controller.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 3 | * 4 | * Defines the {@link ViewController} class. 5 | * 6 | * @module peaks/views/view-controller 7 | */ 8 | 9 | define([ 10 | 'peaks/views/waveform.overview', 11 | 'peaks/views/waveform.zoomview', 12 | 'peaks/waveform/waveform.utils' 13 | ], function( 14 | WaveformOverview, 15 | WaveformZoomView, 16 | Utils) { 17 | 'use strict'; 18 | 19 | /** 20 | * Creates an object that allows users to create and manage waveform views. 21 | * 22 | * @class 23 | * @alias ViewController 24 | * 25 | * @param {Peaks} peaks 26 | */ 27 | 28 | function ViewController(peaks) { 29 | this._peaks = peaks; 30 | this._overview = null; 31 | this._zoomview = null; 32 | } 33 | 34 | ViewController.prototype.createOverview = function(container) { 35 | if (this._overview) { 36 | return this._overview; 37 | } 38 | 39 | var waveformData = this._peaks.getWaveformData(); 40 | 41 | this._overview = new WaveformOverview( 42 | waveformData, 43 | container, 44 | this._peaks 45 | ); 46 | 47 | return this._overview; 48 | }; 49 | 50 | ViewController.prototype.createZoomview = function(container) { 51 | if (this._zoomview) { 52 | return this._zoomview; 53 | } 54 | 55 | var waveformData = this._peaks.getWaveformData(); 56 | 57 | this._zoomview = new WaveformZoomView( 58 | waveformData, 59 | container, 60 | this._peaks 61 | ); 62 | 63 | return this._zoomview; 64 | }; 65 | 66 | ViewController.prototype.destroy = function() { 67 | if (this._overview) { 68 | this._overview.destroy(); 69 | this._overview = null; 70 | } 71 | 72 | if (this._zoomview) { 73 | this._zoomview.destroy(); 74 | this._zoomview = null; 75 | } 76 | }; 77 | 78 | ViewController.prototype.getView = function(name) { 79 | if (Utils.isNullOrUndefined(name)) { 80 | if (this._overview && this._zoomview) { 81 | return null; 82 | } 83 | else if (this._overview) { 84 | return this._overview; 85 | } 86 | else if (this._zoomview) { 87 | return this._zoomview; 88 | } 89 | else { 90 | return null; 91 | } 92 | } 93 | else { 94 | switch (name) { 95 | case 'overview': 96 | return this._overview; 97 | 98 | case 'zoomview': 99 | return this._zoomview; 100 | 101 | default: 102 | return null; 103 | } 104 | } 105 | }; 106 | 107 | return ViewController; 108 | }); 109 | -------------------------------------------------------------------------------- /src/main/views/waveform.zoomcontroller.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 3 | * 4 | * Defines the {@link ZoomController} class. 5 | * 6 | * @module peaks/views/waveform.zoomcontroller.js 7 | */ 8 | 9 | define([], function() { 10 | 'use strict'; 11 | 12 | /** 13 | * Creates an object to control zoom levels in a {@link WaveformZoomView}. 14 | * 15 | * @class 16 | * @alias ZoomController 17 | * 18 | * @param {Peaks} peaks 19 | * @param {Array} zoomLevels 20 | */ 21 | 22 | function ZoomController(peaks, zoomLevels) { 23 | this._peaks = peaks; 24 | this._zoomLevels = zoomLevels; 25 | this._zoomLevelIndex = 0; 26 | } 27 | 28 | ZoomController.prototype.setZoomLevels = function(zoomLevels) { 29 | this._zoomLevels = zoomLevels; 30 | this.setZoom(0, true); 31 | }; 32 | 33 | /** 34 | * Zoom in one level. 35 | */ 36 | 37 | ZoomController.prototype.zoomIn = function() { 38 | this.setZoom(this._zoomLevelIndex - 1); 39 | }; 40 | 41 | /** 42 | * Zoom out one level. 43 | */ 44 | 45 | ZoomController.prototype.zoomOut = function() { 46 | this.setZoom(this._zoomLevelIndex + 1); 47 | }; 48 | 49 | /** 50 | * Given a particular zoom level, triggers a resampling of the data in the 51 | * zoomed view. 52 | * 53 | * @param {number} zoomLevelIndex An index into the options.zoomLevels array. 54 | */ 55 | 56 | ZoomController.prototype.setZoom = function(zoomLevelIndex, forceUpdate) { 57 | if (zoomLevelIndex >= this._zoomLevels.length) { 58 | zoomLevelIndex = this._zoomLevels.length - 1; 59 | } 60 | 61 | if (zoomLevelIndex < 0) { 62 | zoomLevelIndex = 0; 63 | } 64 | 65 | if (!forceUpdate && (zoomLevelIndex === this._zoomLevelIndex)) { 66 | // Nothing to do. 67 | return; 68 | } 69 | 70 | var previousZoomLevelIndex = this._zoomLevelIndex; 71 | 72 | this._zoomLevelIndex = zoomLevelIndex; 73 | 74 | this._peaks.emit( 75 | 'zoom.update', 76 | this._zoomLevels[zoomLevelIndex], 77 | this._zoomLevels[previousZoomLevelIndex] 78 | ); 79 | }; 80 | 81 | /** 82 | * Returns the current zoom level index. 83 | * 84 | * @returns {Number} 85 | */ 86 | 87 | ZoomController.prototype.getZoom = function() { 88 | return this._zoomLevelIndex; 89 | }; 90 | 91 | /** 92 | * Returns the current zoom level, in samples per pixel. 93 | * 94 | * @returns {Number} 95 | */ 96 | 97 | ZoomController.prototype.getZoomLevel = function() { 98 | return this._zoomLevels[this._zoomLevelIndex]; 99 | }; 100 | 101 | return ZoomController; 102 | }); 103 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for your interest in Peaks.js! 4 | 5 | We love hearing feedback from people who use our software, so if you build something interesting using this library, please let us know. 6 | 7 | Contributions are welcomed and encouraged. If you're thinking of writing a new feature, please first discuss the change you wish to make, either by raising an issue, or contacting us directly, e.g, [by email](mailto:irfs@bbc.co.uk). 8 | 9 | ## Making changes 10 | 11 | * If we agree with your feature proposal, we'll work with you to develop and integrate the feature. But please bear with us, as we may not always be able to respond immediately. 12 | 13 | * Please avoid making commits directly to your copy of the `master` branch. This branch is reserved for aggregating changes from other people, and for mainline development from the core contributors. If you commit to `master`, it's likely that your local fork will diverge from the [upstream repository](https://github.com/bbc/peaks.js). 14 | 15 | * Before working on a change, please ensure your local fork is up to date with the code in the upstream repository, and create a [feature branch](https://www.atlassian.com/git/tutorials/comparing-workflows/feature-branch-workflow) for your changes. 16 | 17 | * Please don't change the `version` field in [package.json](https://github.com/bbc/peaks.js/blob/master/package.json), or update [CHANGELOG.md](https://github.com/bbc/peaks.js/blob/master/CHANGELOG.md). We'll do that when [preparing a new release](#preparing-a-new-release). 18 | 19 | * Please follow the existing coding conventions, and ensure that there are no linting errors (`npm run lint`). The eslint config doesn't specify all our coding conventions, so please try to be consistent. (We realise there are some inconsistencies in the codebase already, we're slowly working to resolve them.) 20 | 21 | * For commit messages, please follow [these guidelines](https://chris.beams.io/posts/git-commit/), although we're not fussy about use of imperative mood vs past tense. In particular, avoid commit messages that include [Angular-style metadata](https://github.com/angular/angular/blob/master/CONTRIBUTING.md#-commit-message-guidelines). 22 | 23 | * Please add test cases for your feature, and ensure all tests are passing (`npm run test`). 24 | 25 | * When merging a feature branch, core contributors may choose to squash your commits, so that the feature is merged as a single logical change. 26 | 27 | ### Preparing a new release 28 | 29 | When it's time to publish a new release version, e.g,. to npm, create a single commit on `master` with the following changes only: 30 | 31 | * Increment the `version` field in [package.json](https://github.com/bbc/peaks.js/blob/master/package.json) 32 | * Describe the new features in this release in [CHANGELOG.md](https://github.com/bbc/peaks.js/blob/master/CHANGELOG.md) 33 | 34 | Tag this commit using the form `vX.Y.Z` and push the commit using `git push origin master --tags`. 35 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "browser": true, 4 | "amd": true 5 | }, 6 | "extends": "eslint:recommended", 7 | "rules": { 8 | "array-bracket-spacing": ["error", "never"], 9 | "brace-style": ["error", "stroustrup"], 10 | "block-scoped-var": "error", 11 | "comma-dangle": ["error", "never"], 12 | "comma-style": ["error", "last"], 13 | "consistent-this": ["error", "self"], 14 | "curly": ["error", "all"], 15 | "dot-location": ["error", "property"], 16 | "dot-notation": "error", 17 | "eqeqeq": "error", 18 | "for-direction": "off", 19 | "func-style": ["error", "declaration"], 20 | "guard-for-in": "error", 21 | "indent": ["off", 2], 22 | "key-spacing": ["error", { "beforeColon": false, "afterColon": true, "mode": "minimum" }], 23 | "keyword-spacing": ["error", { "before": true }], 24 | "linebreak-style": ["error", "unix"], 25 | "max-len": ["warn", 100], 26 | "new-cap": "error", 27 | "newline-after-var": ["error", "always"], 28 | "newline-per-chained-call": "error", 29 | "no-alert": "error", 30 | "no-caller": "error", 31 | "no-console": "warn", 32 | "no-continue": "error", 33 | "no-eval": "error", 34 | "no-extra-bind": "error", 35 | "no-extra-semi": "error", 36 | "no-floating-decimal": "error", 37 | "no-implicit-coercion": "error", 38 | "no-implicit-globals": "error", 39 | "no-implied-eval": "error", 40 | "no-label-var": "error", 41 | "no-labels": "error", 42 | "no-lone-blocks": "error", 43 | "no-lonely-if": "off", 44 | "no-loop-func": "error", 45 | "no-multi-str": "error", 46 | "no-multiple-empty-lines": ["error", { "max": 1 }], 47 | "no-nested-ternary": "error", 48 | "no-new-object": "error", 49 | "no-return-assign": "error", 50 | "no-script-url": "error", 51 | "no-self-compare": "error", 52 | "no-sequences": "error", 53 | "no-tabs": "error", 54 | "no-throw-literal": "error", 55 | "no-trailing-spaces": "error", 56 | "no-unmodified-loop-condition": "error", 57 | "no-unused-expressions": "error", 58 | "no-unused-vars": ["warn", { "args": "all" }], 59 | "no-useless-call": "error", 60 | "no-useless-concat": "error", 61 | "no-useless-escape": "error", 62 | "no-undef-init": "error", 63 | "no-use-before-define": "error", 64 | "no-void": "error", 65 | "object-curly-spacing": ["error", "always"], 66 | "operator-assignment": ["error", "always"], 67 | "padded-blocks": ["warn", "never"], 68 | "quotes": ["error", "single", { "avoidEscape": true }], 69 | "radix": "error", 70 | "semi": ["error", "always"], 71 | "space-before-blocks": "error", 72 | "space-before-function-paren": ["error", "never"], 73 | "space-in-parens": ["error", "never"], 74 | "space-infix-ops": "error", 75 | "space-unary-ops": "error", 76 | "spaced-comment": ["error", "always", { "block": { "balanced": true } }], 77 | "yoda": "error" 78 | } 79 | }; 80 | -------------------------------------------------------------------------------- /test/unit/api-player-spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('./setup'); 4 | 5 | var Peaks = require('../../src/main'); 6 | 7 | describe('Peaks.player', function() { 8 | var p; 9 | 10 | beforeEach(function(done) { 11 | p = Peaks.init({ 12 | container: document.getElementById('container'), 13 | mediaElement: document.getElementById('media'), 14 | dataUri: { 15 | json: 'base/test_data/sample.json' 16 | }, 17 | logger: sinon.spy() 18 | }); 19 | 20 | p.on('peaks.ready', done); 21 | }); 22 | 23 | afterEach(function() { 24 | if (p) { 25 | p.destroy(); 26 | } 27 | }); 28 | 29 | describe('getCurrentTime', function() { 30 | var newTime = 6.0; 31 | 32 | it('should return the actual value of the audio element', function() { 33 | expect(p.player.getCurrentTime()).to.equal(0); 34 | }); 35 | 36 | it('should return an updated time if it has been modified through the audio element', function(done) { 37 | p.on('player_seek', function(currentTime) { 38 | var diff = Math.abs(p.player.getCurrentTime() - newTime); 39 | expect(diff).to.be.lessThan(0.2); 40 | done(); 41 | }); 42 | 43 | document.querySelector('audio').currentTime = newTime; 44 | }); 45 | }); 46 | 47 | describe('seek', function() { 48 | beforeEach(function() { 49 | p.player.seek(0.0); 50 | }); 51 | 52 | it('should change the currentTime value of the audio element', function() { 53 | var newTime = 6.0; 54 | 55 | p.player.seek(newTime); 56 | 57 | var diff = Math.abs(p.player.getCurrentTime() - newTime); 58 | expect(diff).to.be.lessThan(0.2); 59 | }); 60 | 61 | it('should log an error if the given time is not valid', function() { 62 | p.player.seek('6.0'); 63 | 64 | expect(p.logger.calledOnce); 65 | expect(p.player.getCurrentTime()).to.equal(0.0); 66 | }); 67 | }); 68 | 69 | describe('playSegment', function() { 70 | it('should log an error if a segment id is given', function() { 71 | p.player.playSegment('peaks.segment.0'); 72 | 73 | expect(p.logger.calledOnce); 74 | }); 75 | 76 | it('should play a given segment', function() { 77 | p.segments.add({ startTime: 10, endTime: 20, editable: true }); 78 | 79 | var segments = p.segments.getSegments(); 80 | expect(segments.length).to.equal(1); 81 | 82 | p.player.playSegment(segments[0]); 83 | p.player.pause(); 84 | 85 | expect(p.logger.notCalled); 86 | }); 87 | 88 | it('should play a segment if an object with startTime and endTime values is given', function() { 89 | p.player.playSegment({ startTime: 10, endTime: 20 }); 90 | p.player.pause(); 91 | 92 | expect(p.logger.notCalled); 93 | }); 94 | }); 95 | 96 | describe('getCurrentSource', function() { 97 | it('should return the media url', function() { 98 | expect(p.player.getCurrentSource()).to.match(/http:\/\/localhost:8080\/base\/test_data\/sample.(?:mp3|ogg)/); 99 | }); 100 | }); 101 | 102 | describe('destroy', function() { 103 | it('should remove all event listeners', function() { 104 | p.player.destroy(); 105 | 106 | expect(p.player._listeners).to.be.empty; 107 | }); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /src/main/views/zooms/animated.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 3 | * 4 | * Defines an animated zoom view adapter. 5 | * 6 | * @module peaks/views/zooms/animated 7 | */ 8 | 9 | define(['konva'], function(Konva) { 10 | 'use strict'; 11 | 12 | return { 13 | 14 | /** 15 | * @param {Number} currentScale 16 | * @param {Number} previousScale 17 | * @param {WaveformZoomView} view 18 | * @returns {Konva.Animation} 19 | */ 20 | 21 | create: function(currentScale, previousScale, view) { 22 | var currentTime = view.peaks.player.getCurrentTime(); 23 | var frameData = []; 24 | 25 | var inputIndex; 26 | var outputIndex; 27 | var lastFrameOffsetTime; 28 | 29 | var rootData = view.originalWaveformData; 30 | 31 | view.beginZoom(); 32 | 33 | // Determine whether zooming in or out 34 | var frameCount = (previousScale < currentScale) ? 15 : 30; 35 | 36 | // Create array with resampled data for each animation frame (need to 37 | // know duration, resample points per frame) 38 | for (var i = 0; i < frameCount; i++) { 39 | // Work out interpolated resample scale using currentScale 40 | // and previousScale 41 | var frameScale = Math.floor( 42 | previousScale + 43 | i * (currentScale - previousScale) / frameCount 44 | ); 45 | 46 | // Determine the timeframe for the zoom animation (start and end of 47 | // dataset for zooming animation) 48 | var newWidthSeconds = view.width * frameScale / rootData.adapter.sample_rate; 49 | 50 | if (currentTime >= 0 && currentTime <= newWidthSeconds / 2) { 51 | inputIndex = 0; 52 | outputIndex = 0; 53 | } 54 | else if (currentTime <= rootData.duration && 55 | currentTime >= rootData.duration - newWidthSeconds / 2) { 56 | lastFrameOffsetTime = rootData.duration - newWidthSeconds; 57 | 58 | inputIndex = lastFrameOffsetTime * rootData.adapter.sample_rate / previousScale; 59 | outputIndex = lastFrameOffsetTime * rootData.adapter.sample_rate / frameScale; 60 | } 61 | else { 62 | // This way calculates the index of the start time at the scale we 63 | // are coming from and the scale we are going to 64 | 65 | var oldPixelIndex = currentTime * rootData.adapter.sample_rate / previousScale; 66 | var newPixelIndex = currentTime * rootData.adapter.sample_rate / frameScale; 67 | 68 | inputIndex = oldPixelIndex - view.width / 2; 69 | outputIndex = newPixelIndex - view.width / 2; 70 | } 71 | 72 | if (inputIndex < 0) { 73 | inputIndex = 0; 74 | } 75 | 76 | // rootData should be swapped for your resampled dataset: 77 | var resampled = rootData.resample({ 78 | scale: frameScale, 79 | input_index: Math.floor(inputIndex), 80 | output_index: Math.floor(outputIndex), 81 | width: view.width 82 | }); 83 | 84 | frameData.push(resampled); 85 | } 86 | 87 | var animationFrameFunction = 88 | this.createAnimationFrameFunction(view, frameData); 89 | 90 | return new Konva.Animation(animationFrameFunction, view); 91 | }, 92 | 93 | createAnimationFrameFunction: function(view, frameData) { 94 | var index = 0; 95 | 96 | view.intermediateData = null; 97 | 98 | /** 99 | * @param {Object} frame 100 | * @this {Konva.Animation} 101 | */ 102 | 103 | return function(frame) { 104 | if (index < frameData.length) { 105 | // Send correct resampled waveform data object to drawFunc and draw it 106 | view.intermediateData = frameData[index]; 107 | index++; 108 | 109 | view.zoomWaveformLayer.draw(); 110 | } 111 | else { 112 | this.stop(); 113 | view.intermediateData = null; 114 | view.endZoom(); 115 | } 116 | }; 117 | } 118 | }; 119 | }); 120 | -------------------------------------------------------------------------------- /src/main/markers/point.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 3 | * 4 | * Defines the {@link Point} class. 5 | * 6 | * @module peaks/markers/point 7 | */ 8 | 9 | define([ 10 | 'peaks/waveform/waveform.utils' 11 | ], function(Utils) { 12 | 'use strict'; 13 | 14 | function validatePoint(time, labelText) { 15 | if (!Utils.isValidTime(time)) { 16 | // eslint-disable-next-line max-len 17 | throw new TypeError('peaks.points.add(): time should be a numeric value'); 18 | } 19 | 20 | if (time < 0) { 21 | // eslint-disable-next-line max-len 22 | throw new TypeError('peaks.points.add(): time should not be negative'); 23 | } 24 | 25 | if (Utils.isNullOrUndefined(labelText)) { 26 | // Set default label text 27 | labelText = ''; 28 | } 29 | else if (!Utils.isString(labelText)) { 30 | throw new TypeError('peaks.points.add(): labelText must be a string'); 31 | } 32 | } 33 | 34 | /** 35 | * A point is a single instant of time, with associated label and color. 36 | * 37 | * @class 38 | * @alias Point 39 | * 40 | * @param {Object} parent A reference to the parent WaveformPoints instance 41 | * @param {String} id A unique identifier for the point. 42 | * @param {Number} time Point time, in seconds. 43 | * @param {String} labelText Point label text. 44 | * @param {String} color Point marker color. 45 | * @param {Boolean} editable If true the segment start and 46 | * end times can be adjusted via the user interface. 47 | */ 48 | 49 | function Point(parent, id, time, labelText, color, editable) { 50 | labelText = labelText || ''; 51 | validatePoint(time, labelText); 52 | this._parent = parent; 53 | this._id = id; 54 | this._time = time; 55 | this._labelText = labelText; 56 | this._color = color; 57 | this._editable = editable; 58 | } 59 | 60 | Object.defineProperties(Point.prototype, { 61 | parent: { 62 | get: function() { 63 | return this._parent; 64 | } 65 | }, 66 | id: { 67 | enumerable: true, 68 | get: function() { 69 | return this._id; 70 | } 71 | }, 72 | time: { 73 | enumerable: true, 74 | get: function() { 75 | return this._time; 76 | }, 77 | 78 | set: function(time) { 79 | this._time = time; 80 | } 81 | }, 82 | labelText: { 83 | get: function() { 84 | return this._labelText; 85 | } 86 | }, 87 | color: { 88 | enumerable: true, 89 | get: function() { 90 | return this._color; 91 | } 92 | }, 93 | editable: { 94 | enumerable: true, 95 | get: function() { 96 | return this._editable; 97 | } 98 | } 99 | }); 100 | 101 | Point.prototype.update = function(options) { 102 | var time = Object.prototype.hasOwnProperty.call(options, 'time') ? options.time : this.time; 103 | var labelText = Object.prototype.hasOwnProperty.call(options, 'labelText') ? options.labelText || '' : this.labelText; 104 | var color = Object.prototype.hasOwnProperty.call(options, 'color') ? options.color : this.color; 105 | var editable = Object.prototype.hasOwnProperty.call(options, 'editable') ? options.editable : this.editable; 106 | 107 | validatePoint(time, labelText); 108 | 109 | this._time = time; 110 | this._labelText = labelText; 111 | this._color = color; 112 | this._editable = editable; 113 | this._parent._peaks.emit('points.update', this); 114 | }; 115 | 116 | /** 117 | * Returns true if the point lies with in a given time range. 118 | * 119 | * @param {Number} startTime The start of the time region, in seconds. 120 | * @param {Number} endTime The end of the time region, in seconds. 121 | * @returns {Boolean} 122 | */ 123 | 124 | Point.prototype.isVisible = function(startTime, endTime) { 125 | return this.time >= startTime && this.time < endTime; 126 | }; 127 | 128 | return Point; 129 | }); 130 | -------------------------------------------------------------------------------- /test/unit/segment-spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('./setup'); 4 | 5 | var Peaks = require('../../src/main'); 6 | var Segment = require('../../src/main/markers/segment'); 7 | 8 | describe('Segment', function() { 9 | describe('update()', function() { 10 | var p; 11 | 12 | beforeEach(function(done) { 13 | p = Peaks.init({ 14 | container: document.getElementById('container'), 15 | mediaElement: document.getElementById('media'), 16 | dataUri: { arraybuffer: 'base/test_data/sample.dat' } 17 | }); 18 | 19 | p.on('peaks.ready', done); 20 | }); 21 | 22 | afterEach(function() { 23 | if (p) { 24 | p.destroy(); 25 | } 26 | }); 27 | 28 | it('should be possible to update all properties programatically', function() { 29 | p.segments.add({ startTime: 0, endTime: 10, labelText: 'label text' }); 30 | 31 | var newLabelText = 'new label text'; 32 | var newStartTime = 2; 33 | var newEndTime = 9; 34 | var segment = p.segments.getSegments()[0]; 35 | 36 | segment.update({ 37 | startTime: newStartTime, 38 | endTime: newEndTime, 39 | labelText: newLabelText 40 | }); 41 | expect(segment.startTime).to.equal(newStartTime); 42 | expect(segment.endTime).to.equal(newEndTime); 43 | expect(segment.labelText).to.equal(newLabelText); 44 | }); 45 | 46 | it('should not allow invalid updates', function() { 47 | p.segments.add({ startTime: 0, endTime: 10 }); 48 | 49 | var segment = p.segments.getSegments()[0]; 50 | 51 | expect(function() { 52 | segment.update({ startTime: NaN }); 53 | }).to.throw(TypeError); 54 | 55 | expect(function() { 56 | segment.update({ endTime: NaN }); 57 | }).to.throw(TypeError); 58 | 59 | expect(function() { 60 | segment.update({ startTime: 8, endTime: 3 }); 61 | }).to.throw(RangeError); 62 | }); 63 | }); 64 | 65 | describe('isVisible', function() { 66 | it('should return false if segment is before visible range', function() { 67 | var segment = new Segment({}, 'segment.1', 0.0, 10.0); 68 | 69 | expect(segment.isVisible(10.0, 20.0)).to.equal(false); 70 | }); 71 | 72 | it('should return false if segment is after visible range', function() { 73 | var segment = new Segment({}, 'segment.1', 20.0, 30.0); 74 | 75 | expect(segment.isVisible(10.0, 20.0)).to.equal(false); 76 | }); 77 | 78 | it('should return true if segment is within visible range', function() { 79 | var segment = new Segment({}, 'segment.1', 12.0, 18.0); 80 | 81 | expect(segment.isVisible(10.0, 20.0)).to.equal(true); 82 | }); 83 | 84 | it('should return true if segment starts before and ends within visible range', function() { 85 | var segment = new Segment({}, 'segment.1', 9.0, 19.0); 86 | 87 | expect(segment.isVisible(10.0, 20.0)).to.equal(true); 88 | }); 89 | 90 | it('should return true if segment starts before and ends at end of visible range', function() { 91 | var segment = new Segment({}, 'segment.1', 9.0, 20.0); 92 | 93 | expect(segment.isVisible(10.0, 20.0)).to.equal(true); 94 | }); 95 | 96 | it('should return true if segment starts after and ends after visible range', function() { 97 | var segment = new Segment({}, 'segment.1', 11.0, 21.0); 98 | 99 | expect(segment.isVisible(10.0, 20.0)).to.equal(true); 100 | }); 101 | 102 | it('should return true if segment starts after and ends at the end of visible range', function() { 103 | var segment = new Segment({}, 'segment.1', 11.0, 20.0); 104 | 105 | expect(segment.isVisible(10.0, 20.0)).to.equal(true); 106 | }); 107 | 108 | it('should return true if segment is same as visible range', function() { 109 | var segment = new Segment({}, 'segment.1', 10.0, 20.0); 110 | 111 | expect(segment.isVisible(10.0, 20.0)).to.equal(true); 112 | }); 113 | 114 | it('should return true if segment contains visible range', function() { 115 | var segment = new Segment({}, 'segment.1', 9.0, 21.0); 116 | 117 | expect(segment.isVisible(10.0, 20.0)).to.equal(true); 118 | }); 119 | }); 120 | }); 121 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "peaks.js", 3 | "version": "0.13.0", 4 | "description": "JavaScript UI component for displaying audio waveforms", 5 | "main": "./peaks.js", 6 | "types": "./peaks.js.d.ts", 7 | "contributors": [ 8 | "Chris Finch (https://github.com/chrisfinch)", 9 | "Thomas Parisot (https://github.com/oncletom)", 10 | "Chris Needham (https://github.com/chrisn)" 11 | ], 12 | "keywords": [ 13 | "audio", 14 | "visualisation", 15 | "bbc", 16 | "webaudio", 17 | "browser", 18 | "interactive", 19 | "waveform" 20 | ], 21 | "browser": { 22 | "EventEmitter": "eventemitter2", 23 | "peaks/player/player": "./src/main/player/player.js", 24 | "peaks/player/player.keyboard": "./src/main/player/player.keyboard.js", 25 | "peaks/waveform/waveform.axis": "./src/main/waveform/waveform.axis.js", 26 | "peaks/waveform/waveform-builder": "./src/main/waveform/waveform-builder.js", 27 | "peaks/waveform/waveform.mixins": "./src/main/waveform/waveform.mixins.js", 28 | "peaks/waveform/waveform.utils": "./src/main/waveform/waveform.utils.js", 29 | "peaks/views/playhead-layer": "./src/main/views/playhead-layer.js", 30 | "peaks/views/points-layer": "./src/main/views/points-layer.js", 31 | "peaks/views/segments-layer": "./src/main/views/segments-layer.js", 32 | "peaks/views/view-controller": "./src/main/views/view-controller.js", 33 | "peaks/views/waveform-shape": "./src/main/views/waveform-shape.js", 34 | "peaks/views/waveform.overview": "./src/main/views/waveform.overview.js", 35 | "peaks/views/waveform.timecontroller": "./src/main/views/waveform.timecontroller.js", 36 | "peaks/views/waveform.zoomcontroller": "./src/main/views/waveform.zoomcontroller.js", 37 | "peaks/views/waveform.zoomview": "./src/main/views/waveform.zoomview.js", 38 | "peaks/views/helpers/mousedraghandler": "./src/main/views/helpers/mousedraghandler.js", 39 | "peaks/views/zooms/animated": "./src/main/views/zooms/animated.js", 40 | "peaks/views/zooms/static": "./src/main/views/zooms/static.js", 41 | "peaks/markers/point": "./src/main/markers/point", 42 | "peaks/markers/segment": "./src/main/markers/segment", 43 | "peaks/markers/waveform.points": "./src/main/markers/waveform.points.js", 44 | "peaks/markers/waveform.segments": "./src/main/markers/waveform.segments.js", 45 | "peaks/cues/cue-emitter": "./src/main/cues/cue-emitter.js", 46 | "peaks/cues/cue": "./src/main/cues/cue.js" 47 | }, 48 | "repository": { 49 | "type": "git", 50 | "url": "git://github.com/bbc/peaks.js.git" 51 | }, 52 | "license": "LGPL-3.0", 53 | "engines": { 54 | "node": ">= 8.11.2" 55 | }, 56 | "scripts": { 57 | "prebuild": "npm run lint", 58 | "build": "browserify -d -e ./src/main.js -t deamdify -s peaks | exorcist peaks.js.map | derequire - > peaks.js", 59 | "postbuild": "cp peaks.js demo && cp peaks.js.map demo", 60 | "doc": "jsdoc --private --destination docs --recurse src", 61 | "lint": "eslint src/**/*.js test/**/*.js karma.conf.js", 62 | "pretest": "npm run build", 63 | "test": "./node_modules/karma/bin/karma start", 64 | "test-watch": "npm test -- --auto-watch --no-single-run", 65 | "copy-demo-files": "cp test_data/sample.mp3 demo && cp test_data/07023003.mp3 demo && cp test_data/07023003-2channel.dat demo", 66 | "prestart": "npm run build && npm run copy-demo-files", 67 | "prepublish": "npm run build", 68 | "start": "serve --listen 8080 --no-clipboard demo" 69 | }, 70 | "devDependencies": { 71 | "browserify": "~16.5.0", 72 | "chai": "~4.2.0", 73 | "deamdify": "~0.3.0", 74 | "derequire": "~2.0.3", 75 | "eslint": "~6.3.0", 76 | "exorcist": "~1.0.1", 77 | "jsdoc": "~3.6.3", 78 | "karma": "~4.3.0", 79 | "karma-browserify": "~6.1.0", 80 | "karma-browserstack-launcher": "~1.5.1", 81 | "karma-chai-sinon": "~0.1.5", 82 | "karma-chrome-launcher": "~3.1.0", 83 | "karma-firefox-launcher": "~1.2.0", 84 | "karma-html2js-preprocessor": "~1.1.0", 85 | "karma-mocha": "~1.3.0", 86 | "karma-safari-launcher": "~1.0.0", 87 | "karma-spec-reporter": "~0.0.32", 88 | "mocha": "~6.2.0", 89 | "serve": "~11.1.0", 90 | "sinon": "~7.4.1", 91 | "sinon-chai": "~3.3.0", 92 | "watchify": "~3.11.1" 93 | }, 94 | "dependencies": { 95 | "colors.css": "~3.0.0", 96 | "eventemitter2": "~5.0.1", 97 | "konva": "~4.0.6", 98 | "waveform-data": "~3.0.0" 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | /* eslint-env node */ 3 | 4 | function filterBrowsers(browsers, re) { 5 | return Object.keys(browsers).filter(function(key) { 6 | return re.test(key); 7 | }); 8 | } 9 | 10 | module.exports = function(config) { 11 | var isCI = Boolean(process.env.CI) && Boolean(process.env.BROWSER_STACK_ACCESS_KEY); 12 | var glob = config.glob || '**/*.js'; 13 | 14 | // Karma configuration 15 | config.set({ 16 | // The root path location that will be used to resolve all relative paths 17 | // defined in 'files' and 'exclude'. 18 | basePath: '', 19 | 20 | frameworks: ['mocha', 'chai-sinon', 'browserify'], 21 | 22 | client: { 23 | chai: { 24 | includeStack: true 25 | }, 26 | mocha: { 27 | timeout: 5000 28 | } 29 | }, 30 | 31 | browserify: { 32 | debug: true, 33 | transform: [ 34 | 'deamdify' 35 | ] 36 | }, 37 | 38 | // list of files / patterns to load in the browser 39 | files: [ 40 | { pattern: 'test/test_img/*', included: false }, 41 | { pattern: 'test_data/*', included: false }, 42 | { pattern: 'test_data/sample.{dat,json}', included: false, served: true }, 43 | { pattern: 'test/unit/' + glob, included: true } 44 | ], 45 | 46 | mime: { 47 | 'application/octet-stream': ['dat'] 48 | }, 49 | 50 | preprocessors: { 51 | 'test/unit/**/*.js': ['browserify'] 52 | }, 53 | 54 | // test results reporter to use 55 | // possible values: dots || progress || growl || spec 56 | reporters: 'spec', 57 | 58 | // web server port 59 | port: 8080, 60 | 61 | // CLI runner port 62 | runnerPort: 9100, 63 | 64 | // enable / disable colors in the output (reporters and logs) 65 | colors: true, 66 | 67 | // level of logging 68 | // possible values: LOG_DISABLE || LOG_ERROR || LOG_WARN || LOG_INFO || LOG_DEBUG 69 | logLevel: config.LOG_INFO, 70 | 71 | // enable / disable watching file and executing tests whenever any file changes 72 | autoWatch: false, 73 | 74 | browserDisconnectTolerance: isCI ? 3 : 1, 75 | browserNoActivityTimeout: isCI ? 120000 : null, 76 | 77 | browserStack: { 78 | build: process.env.TRAVIS_JOB_NUMBER || ('localhost ' + Date.now()), 79 | project: 'bbcrd/peaks.js' 80 | }, 81 | 82 | customLaunchers: { 83 | 'BSChrome27': { 84 | base: 'BrowserStack', 85 | browser: 'chrome', 86 | browser_version: '27.0', 87 | os: 'Windows', 88 | os_version: 'XP' 89 | }, 90 | 'BSChromeLatest': { 91 | base: 'BrowserStack', 92 | browser: 'chrome', 93 | browser_version: 'latest', 94 | os: 'OS X', 95 | os_version: 'Mavericks' 96 | }, 97 | 'BSFirefox26': { 98 | base: 'BrowserStack', 99 | browser: 'firefox', 100 | browser_version: '26.0', 101 | os: 'Windows', 102 | os_version: '7' 103 | }, 104 | 'BSFirefoxLatest': { 105 | base: 'BrowserStack', 106 | browser: 'firefox', 107 | browser_version: 'latest', 108 | os: 'OS X', 109 | os_version: 'Mavericks' 110 | }, 111 | 'BSSafari6': { 112 | base: 'BrowserStack', 113 | browser: 'safari', 114 | browser_version: '6.0', 115 | os: 'OS X', 116 | os_version: 'Lion' 117 | }, 118 | 'BSSafari7': { 119 | base: 'BrowserStack', 120 | browser: 'safari', 121 | browser_version: '7.0', 122 | os: 'OS X', 123 | os_version: 'Mavericks' 124 | }, 125 | 'BSIE9': { 126 | base: 'BrowserStack', 127 | browser: 'ie', 128 | browser_version: '9.0', 129 | os: 'Windows', 130 | os_version: '7' 131 | }, 132 | 'BSIE10': { 133 | base: 'BrowserStack', 134 | browser: 'ie', 135 | browser_version: '10', 136 | os: 'Windows', 137 | os_version: '8' 138 | }, 139 | 'BSIE11': { 140 | base: 'BrowserStack', 141 | browser: 'ie', 142 | browser_version: '11', 143 | os: 'Windows', 144 | os_version: '8.1' 145 | } 146 | }, 147 | 148 | // If browser does not capture in given timeout [ms], kill it 149 | captureTimeout: 120000, 150 | 151 | // Continuous Integration mode 152 | // if true, it capture browsers, run tests and exit 153 | singleRun: true 154 | }); 155 | 156 | config.set({ 157 | browsers: isCI ? filterBrowsers(config.customLaunchers, /^BS/) : ['ChromeHeadless'] 158 | }); 159 | }; 160 | -------------------------------------------------------------------------------- /src/main/waveform/waveform.axis.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 3 | * 4 | * Defines the {@link WaveformAxis} class. 5 | * 6 | * @module peaks/waveform/waveform.axis 7 | */ 8 | 9 | define(['peaks/waveform/waveform.utils', 'konva'], function(Utils, Konva) { 10 | 'use strict'; 11 | 12 | /** 13 | * Creates the waveform axis shapes and adds them to the given view layer. 14 | * 15 | * @class 16 | * @alias WaveformAxis 17 | * 18 | * @param {WaveformOverview|WaveformZoomView} view 19 | * @param {Konva.Layer} layer 20 | * @param {Object} options 21 | * @param {String} options.axisGridlineColor 22 | * @param {String} options.axisLabelColor 23 | */ 24 | 25 | function WaveformAxis(view, layer, options) { 26 | var self = this; 27 | 28 | self._options = options; 29 | 30 | var axisShape = new Konva.Shape({ 31 | fill: 'rgba(38, 255, 161, 1)', 32 | strokeWidth: 0, 33 | opacity: 1, 34 | sceneFunc: function(context) { 35 | self.drawAxis(context, view); 36 | } 37 | }); 38 | 39 | layer.add(axisShape); 40 | } 41 | 42 | /** 43 | * Returns number of seconds for each x-axis marker, appropriate for the 44 | * current zoom level, ensuring that markers are not too close together 45 | * and that markers are placed at intuitive time intervals (i.e., every 1, 46 | * 2, 5, 10, 20, 30 seconds, then every 1, 2, 5, 10, 20, 30 minutes, then 47 | * every 1, 2, 5, 10, 20, 30 hours). 48 | * 49 | * @param {WaveformOverview|WaveformZoomView} view 50 | * @returns {Number} 51 | */ 52 | 53 | WaveformAxis.prototype.getAxisLabelScale = function(view) { 54 | var baseSecs = 1; // seconds 55 | var steps = [1, 2, 5, 10, 20, 30]; 56 | var minSpacing = 60; 57 | var index = 0; 58 | 59 | var secs; 60 | 61 | for (;;) { 62 | secs = baseSecs * steps[index]; 63 | var pixels = view.timeToPixels(secs); 64 | 65 | if (pixels < minSpacing) { 66 | if (++index === steps.length) { 67 | baseSecs *= 60; // seconds -> minutes -> hours 68 | index = 0; 69 | } 70 | } 71 | else { 72 | break; 73 | } 74 | } 75 | 76 | return secs; 77 | }; 78 | 79 | /** 80 | * Draws the time axis and labels onto a view. 81 | * 82 | * @param {Konva.Context} context The context to draw on. 83 | * @param {WaveformOverview|WaveformZoomView} view 84 | */ 85 | 86 | WaveformAxis.prototype.drawAxis = function(context, view) { 87 | var currentFrameStartTime = view.pixelsToTime(view.getFrameOffset()); 88 | 89 | // Draw axis markers 90 | var markerHeight = 10; 91 | 92 | // Time interval between axis markers (seconds) 93 | var axisLabelIntervalSecs = this.getAxisLabelScale(view); 94 | 95 | // Time of first axis marker (seconds) 96 | var firstAxisLabelSecs = Utils.roundUpToNearest(currentFrameStartTime, axisLabelIntervalSecs); 97 | 98 | // Distance between waveform start time and first axis marker (seconds) 99 | var axisLabelOffsetSecs = firstAxisLabelSecs - currentFrameStartTime; 100 | 101 | // Distance between waveform start time and first axis marker (pixels) 102 | var axisLabelOffsetPixels = view.timeToPixels(axisLabelOffsetSecs); 103 | 104 | context.setAttr('strokeStyle', this._options.axisGridlineColor); 105 | context.setAttr('lineWidth', 1); 106 | 107 | // Set text style 108 | context.setAttr('font', '11px sans-serif'); 109 | context.setAttr('fillStyle', this._options.axisLabelColor); 110 | context.setAttr('textAlign', 'left'); 111 | context.setAttr('textBaseline', 'bottom'); 112 | 113 | var secs = firstAxisLabelSecs; 114 | var x; 115 | 116 | var width = view.getWidth(); 117 | var height = view.getHeight(); 118 | 119 | for (;;) { 120 | // Position of axis marker (pixels) 121 | x = axisLabelOffsetPixels + view.timeToPixels(secs - firstAxisLabelSecs); 122 | if (x >= width) { 123 | break; 124 | } 125 | 126 | context.beginPath(); 127 | context.moveTo(x + 0.5, 0); 128 | context.lineTo(x + 0.5, 0 + markerHeight); 129 | context.moveTo(x + 0.5, height); 130 | context.lineTo(x + 0.5, height - markerHeight); 131 | context.stroke(); 132 | 133 | var label = Utils.formatTime(secs, true); 134 | var labelWidth = context.measureText(label).width; 135 | var labelX = x - labelWidth / 2; 136 | var labelY = height - 1 - markerHeight; 137 | 138 | if (labelX >= 0) { 139 | context.fillText(label, labelX, labelY); 140 | } 141 | 142 | secs += axisLabelIntervalSecs; 143 | } 144 | }; 145 | 146 | return WaveformAxis; 147 | }); 148 | -------------------------------------------------------------------------------- /src/main/markers/segment.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 3 | * 4 | * Defines the {@link Segment} class. 5 | * 6 | * @module peaks/markers/segment 7 | */ 8 | 9 | define([ 10 | 'peaks/waveform/waveform.utils' 11 | ], function(Utils) { 12 | 'use strict'; 13 | 14 | function validateSegment(startTime, endTime, validationContext) { 15 | if (!Utils.isValidTime(startTime)) { 16 | // eslint-disable-next-line max-len 17 | throw new TypeError('peaks.segments.' + validationContext + ': startTime should be a valid number'); 18 | } 19 | 20 | if (!Utils.isValidTime(endTime)) { 21 | // eslint-disable-next-line max-len 22 | throw new TypeError('peaks.segments.' + validationContext + ': endTime should be a valid number'); 23 | } 24 | 25 | if (startTime < 0) { 26 | // eslint-disable-next-line max-len 27 | throw new RangeError('peaks.segments.' + validationContext + ': startTime should not be negative'); 28 | } 29 | 30 | if (endTime < 0) { 31 | // eslint-disable-next-line max-len 32 | throw new RangeError('peaks.segments.' + validationContext + ': endTime should not be negative'); 33 | } 34 | 35 | if (endTime <= startTime) { 36 | // eslint-disable-next-line max-len 37 | throw new RangeError('peaks.segments.' + validationContext + ': endTime should be greater than startTime'); 38 | } 39 | } 40 | 41 | /** 42 | * A segment is a region of time, with associated label and color. 43 | * 44 | * @class 45 | * @alias Segment 46 | * 47 | * @param {Object} parent A reference to the parent WaveformSegments instance 48 | * @param {String} id A unique identifier for the segment. 49 | * @param {Number} startTime Segment start time, in seconds. 50 | * @param {Number} endTime Segment end time, in seconds. 51 | * @param {String} labelText Segment label text. 52 | * @param {String} color Segment waveform color. 53 | * @param {Boolean} editable If true the segment start and 54 | * end times can be adjusted via the user interface. 55 | */ 56 | 57 | function Segment(parent, id, startTime, endTime, labelText, color, editable) { 58 | validateSegment(startTime, endTime, 'add()'); 59 | this._parent = parent; 60 | this._id = id; 61 | this._startTime = startTime; 62 | this._endTime = endTime; 63 | this._labelText = labelText; 64 | this._color = color; 65 | this._editable = editable; 66 | } 67 | 68 | Object.defineProperties(Segment.prototype, { 69 | parent: { 70 | get: function() { 71 | return this._parent; 72 | } 73 | }, 74 | id: { 75 | enumerable: true, 76 | get: function() { 77 | return this._id; 78 | } 79 | }, 80 | startTime: { 81 | enumerable: true, 82 | get: function() { 83 | return this._startTime; 84 | }, 85 | 86 | set: function(time) { 87 | this._startTime = time; 88 | } 89 | }, 90 | endTime: { 91 | enumerable: true, 92 | get: function() { 93 | return this._endTime; 94 | }, 95 | 96 | set: function(time) { 97 | this._endTime = time; 98 | } 99 | }, 100 | labelText: { 101 | enumerable: true, 102 | get: function() { 103 | return this._labelText; 104 | } 105 | }, 106 | color: { 107 | enumerable: true, 108 | get: function() { 109 | return this._color; 110 | } 111 | }, 112 | editable: { 113 | enumerable: true, 114 | get: function() { 115 | return this._editable; 116 | } 117 | } 118 | }); 119 | 120 | Segment.prototype.update = function(options) { 121 | var startTime = Object.prototype.hasOwnProperty.call(options, 'startTime') ? options.startTime : this.startTime; 122 | var endTime = Object.prototype.hasOwnProperty.call(options, 'endTime') ? options.endTime : this.endTime; 123 | var labelText = Object.prototype.hasOwnProperty.call(options, 'labelText') ? options.labelText : this.labelText; 124 | var color = Object.prototype.hasOwnProperty.call(options, 'color') ? options.color : this.color; 125 | var editable = Object.prototype.hasOwnProperty.call(options, 'editable') ? options.editable : this.editable; 126 | 127 | validateSegment(startTime, endTime, 'updateTime()'); 128 | 129 | this._startTime = startTime; 130 | this._endTime = endTime; 131 | this._labelText = labelText; 132 | this._color = color; 133 | this._editable = editable; 134 | this._parent._peaks.emit('segments.update', this); 135 | }; 136 | 137 | /** 138 | * Returns true if the segment overlaps a given time region. 139 | * 140 | * @param {Number} startTime The start of the time region, in seconds. 141 | * @param {Number} endTime The end of the time region, in seconds. 142 | * @returns {Boolean} 143 | * 144 | * @see http://wiki.c2.com/?TestIfDateRangesOverlap 145 | */ 146 | 147 | Segment.prototype.isVisible = function(startTime, endTime) { 148 | return this.startTime < endTime && startTime < this.endTime; 149 | }; 150 | 151 | return Segment; 152 | }); 153 | -------------------------------------------------------------------------------- /src/main/views/waveform-shape.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 3 | * 4 | * Defines the {@link WaveformShape} class. 5 | * 6 | * @module peaks/views/waveform-shape 7 | */ 8 | 9 | define(['peaks/waveform/waveform.utils', 'konva'], function(Utils, Konva) { 10 | 'use strict'; 11 | 12 | /** 13 | * Scales the waveform data for drawing on a canvas context. 14 | * 15 | * @param {Number} amplitude The waveform data point amplitude. 16 | * @param {Number} height The height of the waveform, in pixels. 17 | * @param {Number} scale Amplitude scaling factor. 18 | * @returns {Number} The scaled waveform data point. 19 | */ 20 | 21 | function scaleY(amplitude, height, scale) { 22 | var range = 256; 23 | var offset = 128; 24 | 25 | var scaledAmplitude = (amplitude * scale + offset) * height / range; 26 | 27 | return height - Utils.clamp(height - scaledAmplitude, 0, height); 28 | } 29 | 30 | /** 31 | * Waveform shape options. 32 | * 33 | * @typedef {Object} WaveformShapeOptions 34 | * @global 35 | * @property {String} color Waveform color. 36 | * @property {WaveformOverview|WaveformZoomView} view The view object 37 | * that contains the waveform shape. 38 | * @property {Segment?} segment If given, render a waveform image 39 | * covering the segment's time range. Otherwise, render the entire 40 | * waveform duration. 41 | */ 42 | 43 | /** 44 | * Creates a Konva.Shape object that renders a waveform image. 45 | * 46 | * @class 47 | * @alias WaveformShape 48 | * 49 | * @param {WaveformShapeOptions} options 50 | */ 51 | 52 | function WaveformShape(options) { 53 | Konva.Shape.call(this, { 54 | fill: options.color 55 | }); 56 | 57 | this._view = options.view; 58 | this._segment = options.segment; 59 | this._scale = 1.0; 60 | 61 | this.sceneFunc(this._sceneFunc); 62 | } 63 | 64 | WaveformShape.prototype = Object.create(Konva.Shape.prototype); 65 | 66 | WaveformShape.prototype.setAmplitudeScale = function(scale) { 67 | this._scale = scale; 68 | }; 69 | 70 | WaveformShape.prototype.setWaveformColor = function(color) { 71 | this.fill(color); 72 | }; 73 | 74 | WaveformShape.prototype._sceneFunc = function(context) { 75 | var frameOffset = this._view.getFrameOffset(); 76 | var width = this._view.getWidth(); 77 | var height = this._view.getHeight(); 78 | 79 | this._drawWaveform( 80 | context, 81 | this._view.getWaveformData(), 82 | frameOffset, 83 | this._segment ? this._view.timeToPixels(this._segment.startTime) : frameOffset, 84 | this._segment ? this._view.timeToPixels(this._segment.endTime) : frameOffset + width, 85 | width, 86 | height 87 | ); 88 | }; 89 | 90 | /** 91 | * Draws a waveform on a canvas context. 92 | * 93 | * @param {Konva.Context} context The canvas context to draw on. 94 | * @param {WaveformData} waveformData The waveform data to draw. 95 | * @param {Number} frameOffset The start position of the waveform shown 96 | * in the view, in pixels. 97 | * @param {Number} startPixels The start position of the waveform to draw, 98 | * in pixels. 99 | * @param {Number} endPixels The end position of the waveform to draw, 100 | * in pixels. 101 | * @param {Number} width The width of the waveform area, in pixels. 102 | * @param {Number} height The height of the waveform area, in pixels. 103 | */ 104 | 105 | WaveformShape.prototype._drawWaveform = function(context, waveformData, 106 | frameOffset, startPixels, endPixels, width, height) { 107 | if (startPixels < frameOffset) { 108 | startPixels = frameOffset; 109 | } 110 | 111 | var limit = frameOffset + width; 112 | 113 | if (endPixels > limit) { 114 | endPixels = limit; 115 | } 116 | 117 | if (endPixels > waveformData.length) { 118 | endPixels = waveformData.length; 119 | } 120 | 121 | var channels = waveformData.channels; 122 | 123 | var waveformTop = 0; 124 | var waveformHeight = Math.floor(height / channels); 125 | 126 | for (var i = 0; i < channels; i++) { 127 | if (i === channels - 1) { 128 | waveformHeight = height - (channels - 1) * waveformHeight; 129 | } 130 | 131 | this._drawChannel( 132 | context, 133 | waveformData.channel(i), 134 | frameOffset, 135 | startPixels, 136 | endPixels, 137 | waveformTop, 138 | waveformHeight 139 | ); 140 | 141 | waveformTop += waveformHeight; 142 | } 143 | }; 144 | 145 | WaveformShape.prototype._drawChannel = function(context, channel, 146 | frameOffset, startPixels, endPixels, top, height) { 147 | var x, val; 148 | 149 | context.beginPath(); 150 | 151 | for (x = startPixels; x < endPixels; x++) { 152 | val = channel.min_sample(x); 153 | 154 | context.lineTo(x - frameOffset + 0.5, top + scaleY(val, height, this._scale) + 0.5); 155 | } 156 | 157 | for (x = endPixels - 1; x >= startPixels; x--) { 158 | val = channel.max_sample(x); 159 | 160 | context.lineTo(x - frameOffset + 0.5, top + scaleY(val, height, this._scale) + 0.5); 161 | } 162 | 163 | context.closePath(); 164 | 165 | context.fillShape(this); 166 | }; 167 | 168 | return WaveformShape; 169 | }); 170 | -------------------------------------------------------------------------------- /src/main/views/helpers/mousedraghandler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 3 | * 4 | * Defines the {@link MouseDragHandler} class. 5 | * 6 | * @module peaks/views/helpers/mousedraghandler 7 | */ 8 | 9 | define(function() { 10 | 'use strict'; 11 | 12 | /** 13 | * An object to receive callbacks on mouse drag events. Each function is 14 | * called with the current mouse X position, relative to the stage's 15 | * container HTML element. 16 | * 17 | * @typedef {Object} MouseDragHandlers 18 | * @global 19 | * @property {Function} onMouseDown Mouse down event handler. 20 | * @property {Function} onMouseMove Mouse move event handler. 21 | * @property {Function} onMouseUp Mouse up event handler. 22 | */ 23 | 24 | /** 25 | * Creates a handler for mouse events to allow interaction with the waveform 26 | * views by clicking and dragging the mouse. 27 | * 28 | * @class 29 | * @alias MouseDragHandler 30 | * 31 | * @param {Konva.Stage} stage 32 | * @param {MouseDragHandlers} handlers 33 | */ 34 | 35 | function MouseDragHandler(stage, handlers) { 36 | this._stage = stage; 37 | this._handlers = handlers; 38 | this._dragging = false; 39 | this._mouseDown = this.mouseDown.bind(this); 40 | this._mouseUp = this.mouseUp.bind(this); 41 | this._mouseMove = this.mouseMove.bind(this); 42 | 43 | this._stage.on('mousedown', this._mouseDown); 44 | this._stage.on('touchstart', this._mouseDown); 45 | 46 | this._mouseDownClientX = null; 47 | } 48 | 49 | /** 50 | * Mouse down event handler. 51 | * 52 | * @param {MouseEvent} event 53 | */ 54 | 55 | MouseDragHandler.prototype.mouseDown = function(event) { 56 | if (!event.target) { 57 | return; 58 | } 59 | 60 | if (event.target.attrs.draggable) { 61 | return; 62 | } 63 | 64 | if (event.target.parent && event.target.parent.attrs.draggable) { 65 | return; 66 | } 67 | 68 | if (event.type === 'touchstart') { 69 | this._mouseDownClientX = Math.floor(event.evt.touches[0].clientX); 70 | } 71 | else { 72 | this._mouseDownClientX = event.evt.clientX; 73 | } 74 | 75 | if (this._handlers.onMouseDown) { 76 | var mouseDownPosX = this._getMousePosX(this._mouseDownClientX); 77 | 78 | this._handlers.onMouseDown(mouseDownPosX); 79 | } 80 | 81 | // Use the window mousemove and mouseup handlers instead of the 82 | // Konva.Stage ones so that we still receive events if the user moves the 83 | // mouse outside the stage. 84 | window.addEventListener('mousemove', this._mouseMove, false); 85 | window.addEventListener('touchmove', this._mouseMove, false); 86 | window.addEventListener('mouseup', this._mouseUp, false); 87 | window.addEventListener('touchend', this._mouseUp, false); 88 | window.addEventListener('blur', this._mouseUp, false); 89 | }; 90 | 91 | /** 92 | * Mouse move event handler. 93 | * 94 | * @param {MouseEvent} event 95 | */ 96 | 97 | MouseDragHandler.prototype.mouseMove = function(event) { 98 | var clientX = null; 99 | 100 | if (event.type === 'touchmove') { 101 | clientX = Math.floor(event.changedTouches[0].clientX); 102 | } 103 | else { 104 | clientX = event.clientX; 105 | } 106 | 107 | // Don't update on vertical mouse movement. 108 | if (clientX === this._mouseDownClientX) { 109 | return; 110 | } 111 | 112 | this._dragging = true; 113 | 114 | if (this._handlers.onMouseMove) { 115 | var mousePosX = this._getMousePosX(clientX); 116 | 117 | this._handlers.onMouseMove(mousePosX); 118 | } 119 | }; 120 | 121 | /** 122 | * Mouse up event handler. 123 | * 124 | * @param {MouseEvent} event 125 | */ 126 | 127 | MouseDragHandler.prototype.mouseUp = function(event) { 128 | var clientX = null; 129 | 130 | if (event.type === 'touchend') { 131 | clientX = Math.floor(event.changedTouches[0].clientX); 132 | if (event.cancelable) { 133 | event.preventDefault(); 134 | } 135 | } 136 | else { 137 | clientX = event.clientX; 138 | } 139 | 140 | if (this._handlers.onMouseUp) { 141 | var mousePosX = this._getMousePosX(clientX); 142 | 143 | this._handlers.onMouseUp(mousePosX); 144 | } 145 | 146 | window.removeEventListener('mousemove', this._mouseMove, false); 147 | window.removeEventListener('touchmove', this._mouseMove, false); 148 | window.removeEventListener('mouseup', this._mouseUp, false); 149 | window.removeEventListener('touchend', this._mouseUp, false); 150 | window.removeEventListener('blur', this._mouseUp, false); 151 | 152 | this._dragging = false; 153 | }; 154 | 155 | /** 156 | * @returns {Number} The mouse X position, relative to the container that 157 | * received the mouse down event. 158 | * 159 | * @private 160 | * @param {Number} clientX Mouse client X position. 161 | */ 162 | 163 | MouseDragHandler.prototype._getMousePosX = function(clientX) { 164 | var containerPos = this._stage.getContainer().getBoundingClientRect(); 165 | 166 | return clientX - containerPos.left; 167 | }; 168 | 169 | /** 170 | * Returns true if the mouse is being dragged, i.e., moved with 171 | * the mouse button held down. 172 | * 173 | * @returns {Boolean} 174 | */ 175 | 176 | MouseDragHandler.prototype.isDragging = function() { 177 | return this._dragging; 178 | }; 179 | 180 | return MouseDragHandler; 181 | }); 182 | -------------------------------------------------------------------------------- /demo/overview-waveform.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Peaks.js Demo Page 6 | 51 | 52 | 53 |
54 |

Peaks.js

55 |

56 | Peaks.js is a JavaScript library that allows you to display and 57 | interaction with audio waveforms in the browser. 58 |

59 |

60 | It was developed by BBC R&D 61 | to allow audio editors to make accurate clippings of audio content. 62 | You can read more about the project 63 | here. 64 |

65 | 66 |

Demo pages

67 |

68 | The following pages demonstrate various configuration options: 69 |

70 |

71 | Precomputed Waveform Data | 72 | Web Audio API | 73 | Single Zoomable Waveform | 74 | Single Fixed Waveform | 75 | Cue Events | 76 | Changing the Media URL | 77 | Multi-Channel Waveform 78 |

79 |

Demo: Single Fixed Waveform

80 |

81 | This demo shows how to configure Peaks.js to render a single waveform 82 | view, scaled to fit a given container element. 83 |

84 |
85 | 86 |
87 |
88 |
89 | 90 |
91 | 96 | 97 |
98 | 99 | 100 | 101 | 102 | 103 | 104 |
105 |
106 | 107 | 108 | 166 | 167 | 168 | -------------------------------------------------------------------------------- /src/main/waveform/waveform.utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 3 | * 4 | * Some general utility functions. 5 | * 6 | * @module peaks/waveform/waveform.utils 7 | */ 8 | 9 | define(function() { 10 | 'use strict'; 11 | 12 | if (typeof Number.isFinite !== 'function') { 13 | Number.isFinite = function isFinite(value) { 14 | if (typeof value !== 'number') { 15 | return false; 16 | } 17 | 18 | // Check for NaN and infinity 19 | // eslint-disable-next-line no-self-compare 20 | if (value !== value || value === Infinity || value === -Infinity) { 21 | return false; 22 | } 23 | 24 | return true; 25 | }; 26 | } 27 | 28 | function zeroPad(number) { 29 | return number < 10 ? '0' + number : number; 30 | } 31 | 32 | return { 33 | 34 | /** 35 | * Returns a formatted time string. 36 | * 37 | * @param {Number} time The time to be formatted, in seconds. 38 | * @param {Boolean} dropHundredths Don't display hundredths of a second if true. 39 | * @returns {String} 40 | */ 41 | 42 | formatTime: function(time, dropHundredths) { 43 | var result = []; 44 | 45 | var hundredths = Math.floor((time % 1) * 100); 46 | var seconds = Math.floor(time); 47 | var minutes = Math.floor(seconds / 60); 48 | var hours = Math.floor(minutes / 60); 49 | 50 | if (hours > 0) { 51 | result.push(hours); // Hours 52 | } 53 | result.push(minutes % 60); // Mins 54 | result.push(seconds % 60); // Seconds 55 | 56 | for (var i = 0; i < result.length; i++) { 57 | result[i] = zeroPad(result[i]); 58 | } 59 | 60 | result = result.join(':'); 61 | 62 | if (!dropHundredths) { 63 | result += '.' + zeroPad(hundredths); 64 | } 65 | 66 | return result; 67 | }, 68 | 69 | /** 70 | * Rounds the given value up to the nearest given multiple. 71 | * 72 | * @param {Number} value 73 | * @param {Number} multiple 74 | * @returns {Number} 75 | * 76 | * @example 77 | * roundUpToNearest(5.5, 3); // returns 6 78 | * roundUpToNearest(141.0, 10); // returns 150 79 | * roundUpToNearest(-5.5, 3); // returns -6 80 | */ 81 | 82 | roundUpToNearest: function(value, multiple) { 83 | if (multiple === 0) { 84 | return 0; 85 | } 86 | 87 | var multiplier = 1; 88 | 89 | if (value < 0.0) { 90 | multiplier = -1; 91 | value = -value; 92 | } 93 | 94 | var roundedUp = Math.ceil(value); 95 | 96 | return multiplier * (((roundedUp + multiple - 1) / multiple) | 0) * multiple; 97 | }, 98 | 99 | clamp: function(value, min, max) { 100 | if (value < min) { 101 | return min; 102 | } 103 | else if (value > max) { 104 | return max; 105 | } 106 | else { 107 | return value; 108 | } 109 | }, 110 | 111 | extend: function(to, from) { 112 | for (var key in from) { 113 | if (Object.prototype.hasOwnProperty.call(from, key)) { 114 | to[key] = from[key]; 115 | } 116 | } 117 | 118 | return to; 119 | }, 120 | 121 | /** 122 | * Checks whether the given array contains values in ascending order. 123 | * 124 | * @param {Array} array The array to test 125 | * @returns {Boolean} 126 | */ 127 | 128 | isInAscendingOrder: function(array) { 129 | if (array.length === 0) { 130 | return true; 131 | } 132 | 133 | var value = array[0]; 134 | 135 | for (var i = 1; i < array.length; i++) { 136 | if (value >= array[i]) { 137 | return false; 138 | } 139 | 140 | value = array[i]; 141 | } 142 | 143 | return true; 144 | }, 145 | 146 | /** 147 | * Checks whether the given value is a number. 148 | * 149 | * @param {Number} value The value to test 150 | * @returns {Boolean} 151 | */ 152 | 153 | isNumber: function(value) { 154 | return typeof value === 'number'; 155 | }, 156 | 157 | /** 158 | * Checks whether the given value is a valid timestamp. 159 | * 160 | * @param {Number} value The value to test 161 | * @returns {Boolean} 162 | */ 163 | 164 | isValidTime: function(value) { 165 | return (typeof value === 'number') && Number.isFinite(value); 166 | }, 167 | 168 | /** 169 | * Checks whether the given value is a valid object. 170 | * 171 | * @param {Object|Array} value The value to test 172 | * @returns {Boolean} 173 | */ 174 | 175 | isObject: function(value) { 176 | return (value !== null) && (typeof value === 'object') 177 | && !Array.isArray(value); 178 | }, 179 | 180 | /** 181 | * Checks whether the given value is a valid string. 182 | * 183 | * @param {String} value The value to test 184 | * @returns {Boolean} 185 | */ 186 | 187 | isString: function(value) { 188 | return typeof value === 'string'; 189 | }, 190 | 191 | /** 192 | * Checks whether the given value is null or undefined. 193 | * 194 | * @param {Object} value The value to test 195 | * @returns {Boolean} 196 | */ 197 | 198 | isNullOrUndefined: function(value) { 199 | return value === undefined || value === null; 200 | }, 201 | 202 | /** 203 | * Checks whether the given value is a function. 204 | * 205 | * @param {Function} value The value to test 206 | * @returns {Boolean} 207 | */ 208 | 209 | isFunction: function(value) { 210 | return typeof value === 'function'; 211 | }, 212 | 213 | /** 214 | * Checks whether the given value is a valid HTML element. 215 | * 216 | * @param {HTMLElement} value The value to test 217 | * @returns {Boolean} 218 | */ 219 | 220 | isHTMLElement: function(value) { 221 | return value instanceof HTMLElement; 222 | } 223 | }; 224 | }); 225 | -------------------------------------------------------------------------------- /demo/zoomable-waveform.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Peaks.js Demo Page 6 | 51 | 52 | 53 |
54 |

Peaks.js

55 |

56 | Peaks.js is a JavaScript library that allows you to display and 57 | interaction with audio waveforms in the browser. 58 |

59 |

60 | It was developed by BBC R&D 61 | to allow audio editors to make accurate clippings of audio content. 62 | You can read more about the project 63 | here. 64 |

65 | 66 |

Demo pages

67 |

68 | The following pages demonstrate various configuration options: 69 |

70 |

71 | Precomputed Waveform Data | 72 | Web Audio API | 73 | Single Zoomable Waveform | 74 | Single Fixed Waveform | 75 | Cue Events | 76 | Changing the Media URL | 77 | Multi-Channel Waveform 78 |

79 |

Demo: Single Zoomable Waveform

80 |

81 | This demo shows how configure Peaks.js to render a single zoomable 82 | waveform view. 83 |

84 |
85 | 86 |
87 |
88 |
89 | 90 |
91 | 96 | 97 |
98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 |
107 |
108 | 109 | 110 | 176 | 177 | 178 | -------------------------------------------------------------------------------- /src/main/player/player.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 3 | * 4 | * Defines the {@link Player} class. 5 | * 6 | * @module peaks/player/player 7 | */ 8 | 9 | define(['peaks/waveform/waveform.utils'], function(Utils) { 10 | 'use strict'; 11 | 12 | /** 13 | * A wrapper for interfacing with the HTML5 media element API. 14 | * Initializes the player for a given media element. 15 | * 16 | * @class 17 | * @alias Player 18 | * 19 | * @param {Peaks} peaks The parent {@link Peaks} object. 20 | * @param {HTMLMediaElement} mediaElement The HTML <audio> 21 | * or <video> element to associate with the 22 | * {@link Peaks} instance. 23 | */ 24 | 25 | function Player(peaks, mediaElement) { 26 | var self = this; 27 | 28 | self._peaks = peaks; 29 | self._listeners = []; 30 | self._mediaElement = mediaElement; 31 | self._duration = self.getDuration(); 32 | self._isPlaying = false; 33 | 34 | if (self._mediaElement.readyState === 4) { 35 | self._peaks.emit('player_load', self); 36 | } 37 | 38 | self._addMediaListener('timeupdate', function() { 39 | self._peaks.emit('player_time_update', self.getCurrentTime()); 40 | }); 41 | 42 | self._addMediaListener('play', function() { 43 | self._isPlaying = true; 44 | self._peaks.emit('player_play', self.getCurrentTime()); 45 | }); 46 | 47 | self._addMediaListener('pause', function() { 48 | self._isPlaying = false; 49 | self._peaks.emit('player_pause', self.getCurrentTime()); 50 | }); 51 | 52 | self._addMediaListener('seeked', function() { 53 | self._peaks.emit('player_seek', self.getCurrentTime()); 54 | }); 55 | 56 | self._addMediaListener('canplay', function() { 57 | self._peaks.emit('player_canplay', self); 58 | }); 59 | 60 | self._addMediaListener('error', function(event) { 61 | self._peaks.emit('player_error', event.target.error); 62 | }); 63 | 64 | self._interval = null; 65 | } 66 | 67 | /** 68 | * Adds an event listener to the media element. 69 | * 70 | * @private 71 | * @param {String} type The event type to listen for. 72 | * @param {Function} callback An event handler function. 73 | */ 74 | 75 | Player.prototype._addMediaListener = function(type, callback) { 76 | this._listeners.push({ type: type, callback: callback }); 77 | this._mediaElement.addEventListener(type, callback); 78 | }; 79 | 80 | /** 81 | * Cleans up the player object, removing all event listeners from the 82 | * associated media element. 83 | */ 84 | 85 | Player.prototype.destroy = function() { 86 | for (var i = 0; i < this._listeners.length; i++) { 87 | var listener = this._listeners[i]; 88 | 89 | this._mediaElement.removeEventListener( 90 | listener.type, 91 | listener.callback 92 | ); 93 | } 94 | 95 | this._listeners.length = 0; 96 | 97 | if (this._interval !== null) { 98 | clearTimeout(this._interval); 99 | this._interval = null; 100 | } 101 | }; 102 | 103 | Player.prototype.setSource = function(source) { 104 | this._mediaElement.setAttribute('src', source); 105 | }; 106 | 107 | Player.prototype.getSource = function() { 108 | return this._mediaElement.src; 109 | }; 110 | 111 | Player.prototype.getCurrentSource = function() { 112 | return this._mediaElement.currentSrc; 113 | }; 114 | 115 | /** 116 | * Starts playback. 117 | */ 118 | 119 | Player.prototype.play = function() { 120 | this._mediaElement.play(); 121 | }; 122 | 123 | /** 124 | * Pauses playback. 125 | */ 126 | 127 | Player.prototype.pause = function() { 128 | this._mediaElement.pause(); 129 | }; 130 | 131 | /** 132 | * @returns {Boolean} true if playing, false 133 | * otherwise. 134 | */ 135 | 136 | Player.prototype.isPlaying = function() { 137 | return this._isPlaying; 138 | }; 139 | 140 | /** 141 | * @returns {boolean} true if seeking 142 | */ 143 | Player.prototype.isSeeking = function() { 144 | return this._mediaElement.seeking; 145 | }; 146 | 147 | /** 148 | * Returns the current playback time position, in seconds. 149 | * 150 | * @returns {Number} 151 | */ 152 | 153 | Player.prototype.getCurrentTime = function() { 154 | return this._mediaElement.currentTime; 155 | }; 156 | 157 | /** 158 | * Returns the media duration, in seconds. 159 | * 160 | * @returns {Number} 161 | */ 162 | 163 | Player.prototype.getDuration = function() { 164 | return this._mediaElement.duration; 165 | }; 166 | 167 | /** 168 | * Seeks to a given time position within the media. 169 | * 170 | * @param {Number} time The time position, in seconds. 171 | */ 172 | 173 | Player.prototype.seek = function(time) { 174 | if (!Utils.isValidTime(time)) { 175 | this._peaks.logger('peaks.player.seek(): parameter must be a valid time, in seconds'); 176 | return; 177 | } 178 | 179 | this._mediaElement.currentTime = time; 180 | }; 181 | 182 | /** 183 | * Plays the given segment. 184 | * 185 | * @param {Segment} segment The segment denoting the time region to play. 186 | */ 187 | 188 | Player.prototype.playSegment = function(segment) { 189 | var self = this; 190 | 191 | if (!segment || 192 | !Utils.isValidTime(segment.startTime) || 193 | !Utils.isValidTime(segment.endTime)) { 194 | self._peaks.logger('peaks.player.playSegment(): parameter must be a segment object'); 195 | return; 196 | } 197 | 198 | clearTimeout(self._interval); 199 | self._interval = null; 200 | 201 | // Set audio time to segment start time 202 | self.seek(segment.startTime); 203 | 204 | // Start playing audio 205 | self._mediaElement.play(); 206 | 207 | // We need to use setInterval here as the timeupdate event doesn't fire 208 | // often enough. 209 | self._interval = setInterval(function() { 210 | if (self.getCurrentTime() >= segment.endTime || self._mediaElement.paused) { 211 | clearTimeout(self._interval); 212 | self._interval = null; 213 | self._mediaElement.pause(); 214 | } 215 | }, 30); 216 | }; 217 | 218 | return Player; 219 | }); 220 | -------------------------------------------------------------------------------- /demo/set-source.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Peaks.js Demo Page 6 | 79 | 80 | 81 |
82 |

Peaks.js

83 |

84 | Peaks.js is a JavaScript library that allows you to display and 85 | interaction with audio waveforms in the browser. 86 |

87 |

88 | It was developed by BBC R&D 89 | to allow audio editors to make accurate clippings of audio content. 90 | You can read more about the project 91 | here. 92 |

93 | 94 |

Demo pages

95 |

96 | The following pages demonstrate various configuration options: 97 |

98 |

99 | Precomputed Waveform Data | 100 | Web Audio API | 101 | Single Zoomable Waveform | 102 | Single Fixed Waveform | 103 | Cue Events | 104 | Changing the Media URL | 105 | Multi-Channel Waveform 106 |

107 |

Demo: Changing the Media URL

108 |

109 | This demo shows how to change the media URL and update the waveform. 110 |

111 |
112 | 113 |
114 |
115 |
116 |
117 | 118 |
119 | 124 | 125 |
126 | 127 | 128 | 129 | 130 |
131 |
132 | 133 | 134 | 200 | 201 | 202 | -------------------------------------------------------------------------------- /test/unit/utils-spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('./setup'); 4 | 5 | var Utils = require('../../src/main/waveform/waveform.utils'); 6 | 7 | describe('Utils', function() { 8 | describe('formatTime', function() { 9 | context('with hundredths', function() { 10 | var tests = [ 11 | { input: 0, output: '00:00.00' }, 12 | { input: 1, output: '00:01.00' }, 13 | { input: 60, output: '01:00.00' }, 14 | { input: 60 * 60, output: '01:00:00.00' }, 15 | { input: 24 * 60 * 60, output: '24:00:00.00' }, 16 | { input: 10.5, output: '00:10.50' } 17 | ]; 18 | 19 | tests.forEach(function(test) { 20 | context('given ' + test.input, function() { 21 | it('should output ' + test.output, function() { 22 | expect(Utils.formatTime(test.input, false)).to.equal(test.output); 23 | }); 24 | }); 25 | }); 26 | }); 27 | 28 | context('without hundredths', function() { 29 | var tests = [ 30 | { input: 0, output: '00:00' }, 31 | { input: 1, output: '00:01' }, 32 | { input: 60, output: '01:00' }, 33 | { input: 60 * 60, output: '01:00:00' }, 34 | { input: 24 * 60 * 60, output: '24:00:00' }, 35 | { input: 10.5, output: '00:10' } 36 | ]; 37 | 38 | tests.forEach(function(test) { 39 | context('given ' + test.input, function() { 40 | it('should output ' + test.output, function() { 41 | expect(Utils.formatTime(test.input, true)).to.equal(test.output); 42 | }); 43 | }); 44 | }); 45 | }); 46 | }); 47 | 48 | describe('roundUpToNearest', function() { 49 | it('should return an integer', function() { 50 | expect(Utils.roundUpToNearest(0.1523809523809524, 1)).to.equal(1); 51 | }); 52 | 53 | it('should round upwards', function() { 54 | expect(Utils.roundUpToNearest(5.5, 3)).to.equal(6); 55 | expect(Utils.roundUpToNearest(38.9, 5)).to.equal(40); 56 | expect(Utils.roundUpToNearest(141.0, 10)).to.equal(150); 57 | }); 58 | 59 | it('should round negative values towards negative infinity', function() { 60 | expect(Utils.roundUpToNearest(-5.5, 3)).to.equal(-6); 61 | }); 62 | 63 | it('should return 0 given a multiple of 0', function() { 64 | expect(Utils.roundUpToNearest(5.5, 0)).to.equal(0); 65 | }); 66 | }); 67 | 68 | describe('clamp', function() { 69 | it('should given value if in range', function() { 70 | expect(Utils.clamp(15, 10, 20)).to.equal(15); 71 | expect(Utils.clamp(-15, -20, -10)).to.equal(-15); 72 | }); 73 | 74 | it('should return minimum if given value is lower', function() { 75 | expect(Utils.clamp(1, 10, 20)).to.equal(10); 76 | expect(Utils.clamp(-21, -20, -10)).to.equal(-20); 77 | }); 78 | 79 | it('should return maximum if given value is higher', function() { 80 | expect(Utils.clamp(21, 10, 20)).to.equal(20); 81 | expect(Utils.clamp(-9, -20, -10)).to.equal(-10); 82 | }); 83 | }); 84 | 85 | describe('isInAscendingOrder', function() { 86 | it('should accept an empty array', function() { 87 | expect(Utils.isInAscendingOrder([])).to.equal(true); 88 | }); 89 | 90 | it('should accept a sorted array', function() { 91 | expect(Utils.isInAscendingOrder([1, 2, 3, 4])).to.equal(true); 92 | }); 93 | 94 | it('should reject an array with duplicate values', function() { 95 | expect(Utils.isInAscendingOrder([1, 1, 2, 3])).to.equal(false); 96 | }); 97 | 98 | it('should reject an array in the wrong order', function() { 99 | expect(Utils.isInAscendingOrder([4, 3, 2, 1])).to.equal(false); 100 | }); 101 | }); 102 | 103 | describe('isValidTime', function() { 104 | it('should accept valid numbers', function() { 105 | expect(Utils.isValidTime(1.0)).to.equal(true); 106 | expect(Utils.isValidTime(-1.0)).to.equal(true); 107 | }); 108 | 109 | it('should reject strings', function() { 110 | expect(Utils.isValidTime('1.0')).to.equal(false); 111 | expect(Utils.isValidTime('test')).to.equal(false); 112 | }); 113 | 114 | it('should reject invalid numbers', function() { 115 | expect(Utils.isValidTime(Infinity)).to.equal(false); 116 | expect(Utils.isValidTime(-Infinity)).to.equal(false); 117 | expect(Utils.isValidTime(NaN)).to.equal(false); 118 | }); 119 | 120 | it('should reject other non-numeric values', function() { 121 | expect(Utils.isValidTime(null)).to.equal(false); 122 | expect(Utils.isValidTime(undefined)).to.equal(false); 123 | expect(Utils.isValidTime({})).to.equal(false); 124 | expect(Utils.isValidTime([])).to.equal(false); 125 | expect(Utils.isValidTime(function foo() {})).to.equal(false); 126 | }); 127 | }); 128 | 129 | describe('isObject', function() { 130 | it('should accept objects', function() { 131 | expect(Utils.isObject({})).to.equal(true); 132 | }); 133 | 134 | it('should reject functions', function() { 135 | expect(Utils.isObject(function foo() {})).to.equal(false); 136 | }); 137 | 138 | it('should reject arrays', function() { 139 | expect(Utils.isObject([])).to.equal(false); 140 | }); 141 | 142 | it('should reject other non-object values', function() { 143 | expect(Utils.isObject(null)).to.equal(false); 144 | expect(Utils.isObject(undefined)).to.equal(false); 145 | expect(Utils.isObject('test')).to.equal(false); 146 | expect(Utils.isObject(1.0)).to.equal(false); 147 | }); 148 | }); 149 | 150 | describe('isString', function() { 151 | it('should accept strings', function() { 152 | expect(Utils.isString('')).to.equal(true); 153 | expect(Utils.isString('test')).to.equal(true); 154 | }); 155 | 156 | it('should reject numbers', function() { 157 | expect(Utils.isString(1.0)).to.equal(false); 158 | expect(Utils.isString(-1.0)).to.equal(false); 159 | }); 160 | 161 | it('should reject non-string values', function() { 162 | expect(Utils.isString(null)).to.equal(false); 163 | expect(Utils.isString(undefined)).to.equal(false); 164 | expect(Utils.isString({})).to.equal(false); 165 | expect(Utils.isString([])).to.equal(false); 166 | expect(Utils.isString(function foo() {})).to.equal(false); 167 | }); 168 | }); 169 | 170 | describe('isNullOrUndefined', function() { 171 | it('should accept null or undefined', function() { 172 | expect(Utils.isNullOrUndefined(null)).to.equal(true); 173 | expect(Utils.isNullOrUndefined(undefined)).to.equal(true); 174 | }); 175 | 176 | it('should reject other values', function() { 177 | expect(Utils.isNullOrUndefined('')).to.equal(false); 178 | expect(Utils.isNullOrUndefined(0)).to.equal(false); 179 | expect(Utils.isNullOrUndefined({})).to.equal(false); 180 | expect(Utils.isNullOrUndefined([])).to.equal(false); 181 | expect(Utils.isNullOrUndefined(function foo() {})).to.equal(false); 182 | }); 183 | }); 184 | 185 | describe('isFunction', function() { 186 | it('should accept functions', function() { 187 | expect(Utils.isFunction(function foo() {})).to.equal(true); 188 | }); 189 | 190 | it('should reject other values', function() { 191 | expect(Utils.isFunction(null)).to.equal(false); 192 | expect(Utils.isFunction(undefined)).to.equal(false); 193 | expect(Utils.isFunction('')).to.equal(false); 194 | expect(Utils.isFunction(0)).to.equal(false); 195 | expect(Utils.isFunction({})).to.equal(false); 196 | expect(Utils.isFunction([])).to.equal(false); 197 | }); 198 | }); 199 | }); 200 | -------------------------------------------------------------------------------- /src/main/views/playhead-layer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 3 | * 4 | * Defines the {@link PlayheadLayer} class. 5 | * 6 | * @module peaks/views/playhead-layer 7 | */ 8 | 9 | define([ 10 | 'peaks/waveform/waveform.utils', 11 | 'konva' 12 | ], function(Utils, Konva) { 13 | 'use strict'; 14 | 15 | /** 16 | * Creates a Konva.Layer that displays a playhead marker. 17 | * 18 | * @class 19 | * @alias PlayheadLayer 20 | * 21 | * @param {Peaks} peaks 22 | * @param {WaveformOverview|WaveformZoomView} view 23 | * @param {Boolean} showTime If true The playback time position 24 | * is shown next to the playhead. 25 | * @param {Number} time Initial position of the playhead, in seconds. 26 | */ 27 | 28 | function PlayheadLayer(peaks, view, showTime, time) { 29 | this._peaks = peaks; 30 | this._view = view; 31 | this._playheadPixel = 0; 32 | this._playheadLineAnimation = null; 33 | this._playheadVisible = false; 34 | this._playheadColor = peaks.options.playheadColor; 35 | this._playheadTextColor = peaks.options.playheadTextColor; 36 | 37 | this._playheadLayer = new Konva.Layer(); 38 | 39 | this._createPlayhead(this._playheadColor); 40 | 41 | if (showTime) { 42 | this._createPlayheadText(this._playheadTextColor); 43 | } 44 | 45 | this.zoomLevelChanged(); 46 | this._syncPlayhead(time); 47 | } 48 | 49 | /** 50 | * Adds the layer to the given {Konva.Stage}. 51 | * 52 | * @param {Konva.Stage} stage 53 | */ 54 | 55 | PlayheadLayer.prototype.addToStage = function(stage) { 56 | stage.add(this._playheadLayer); 57 | }; 58 | 59 | /** 60 | * Decides whether to use an animation to update the playhead position. 61 | * 62 | * If the zoom level is such that the number of pixels per second of audio is 63 | * low, we can use timeupdate events from the HTMLMediaElement to 64 | * set the playhead position. Otherwise, we use an animation to update the 65 | * playhead position more smoothly. The animation is CPU intensive, so we 66 | * avoid using it where possible. 67 | */ 68 | 69 | PlayheadLayer.prototype.zoomLevelChanged = function() { 70 | var pixelsPerSecond = this._view.timeToPixels(1.0); 71 | var time; 72 | 73 | this._useAnimation = pixelsPerSecond >= 5; 74 | 75 | if (this._useAnimation) { 76 | if (this._peaks.player.isPlaying() && !this._playheadLineAnimation) { 77 | // Start the animation 78 | this._start(); 79 | } 80 | } 81 | else { 82 | if (this._playheadLineAnimation) { 83 | // Stop the animation 84 | time = this._peaks.player.getCurrentTime(); 85 | 86 | this.stop(time); 87 | } 88 | } 89 | }; 90 | 91 | /** 92 | * Creates the playhead UI objects. 93 | * 94 | * @private 95 | * @param {String} color 96 | */ 97 | 98 | PlayheadLayer.prototype._createPlayhead = function(color) { 99 | this._playheadLine = new Konva.Line({ 100 | points: [0.5, 0, 0.5, this._view.getHeight()], 101 | stroke: color, 102 | strokeWidth: 1 103 | }); 104 | 105 | this._playheadGroup = new Konva.Group({ 106 | x: 0, 107 | y: 0 108 | }); 109 | 110 | this._playheadGroup.add(this._playheadLine); 111 | this._playheadLayer.add(this._playheadGroup); 112 | }; 113 | 114 | PlayheadLayer.prototype._createPlayheadText = function(color) { 115 | this._playheadText = new Konva.Text({ 116 | x: 2, 117 | y: 12, 118 | text: '00:00:00', 119 | fontSize: 11, 120 | fontFamily: 'sans-serif', 121 | fill: color, 122 | align: 'right' 123 | }); 124 | 125 | this._playheadGroup.add(this._playheadText); 126 | }; 127 | 128 | /** 129 | * Updates the playhead position. 130 | * 131 | * @param {Number} time Current playhead position, in seconds. 132 | */ 133 | 134 | PlayheadLayer.prototype.updatePlayheadTime = function(time) { 135 | this._syncPlayhead(time); 136 | 137 | if (this._peaks.player.isPlaying()) { 138 | this._start(); 139 | } 140 | }; 141 | 142 | /** 143 | * Updates the playhead position. 144 | * 145 | * @private 146 | * @param {Number} time Current playhead position, in seconds. 147 | */ 148 | 149 | PlayheadLayer.prototype._syncPlayhead = function(time) { 150 | var pixelIndex = this._view.timeToPixels(time); 151 | 152 | var frameOffset = this._view.getFrameOffset(); 153 | var width = this._view.getWidth(); 154 | 155 | var isVisible = (pixelIndex >= frameOffset) && 156 | (pixelIndex < frameOffset + width); 157 | 158 | this._playheadPixel = pixelIndex; 159 | 160 | if (isVisible) { 161 | var playheadX = this._playheadPixel - frameOffset; 162 | 163 | if (!this._playheadVisible) { 164 | this._playheadVisible = true; 165 | this._playheadGroup.show(); 166 | } 167 | 168 | this._playheadGroup.setAttr('x', playheadX); 169 | 170 | if (this._playheadText) { 171 | var text = Utils.formatTime(time, false); 172 | 173 | this._playheadText.setText(text); 174 | } 175 | 176 | this._playheadLayer.draw(); 177 | } 178 | else { 179 | if (this._playheadVisible) { 180 | this._playheadVisible = false; 181 | this._playheadGroup.hide(); 182 | 183 | this._playheadLayer.draw(); 184 | } 185 | } 186 | }; 187 | 188 | /** 189 | * Starts a playhead animation in sync with the media playback. 190 | * 191 | * @private 192 | */ 193 | 194 | PlayheadLayer.prototype._start = function() { 195 | var self = this; 196 | 197 | if (self._playheadLineAnimation) { 198 | self._playheadLineAnimation.stop(); 199 | self._playheadLineAnimation = null; 200 | } 201 | 202 | if (!self._useAnimation) { 203 | return; 204 | } 205 | 206 | var lastPlayheadPosition = null; 207 | 208 | self._playheadLineAnimation = new Konva.Animation(function() { 209 | var time = self._peaks.player.getCurrentTime(); 210 | var playheadPosition = self._view.timeToPixels(time); 211 | 212 | if (playheadPosition !== lastPlayheadPosition) { 213 | self._syncPlayhead(time); 214 | lastPlayheadPosition = playheadPosition; 215 | } 216 | }, self._playheadLayer); 217 | 218 | self._playheadLineAnimation.start(); 219 | }; 220 | 221 | PlayheadLayer.prototype.stop = function(time) { 222 | if (this._playheadLineAnimation) { 223 | this._playheadLineAnimation.stop(); 224 | this._playheadLineAnimation = null; 225 | } 226 | 227 | this._syncPlayhead(time); 228 | }; 229 | 230 | /** 231 | * Returns the position of the playhead marker, in pixels relative to the 232 | * left hand side of the waveform view. 233 | * 234 | * @return {Number} 235 | */ 236 | 237 | PlayheadLayer.prototype.getPlayheadOffset = function() { 238 | return this._playheadPixel - this._view.getFrameOffset(); 239 | }; 240 | 241 | PlayheadLayer.prototype.getPlayheadPixel = function() { 242 | return this._playheadPixel; 243 | }; 244 | 245 | PlayheadLayer.prototype.showPlayheadTime = function(show) { 246 | var updated = false; 247 | 248 | if (show) { 249 | if (!this._playheadText) { 250 | // Create it 251 | this._createPlayheadText(this._playheadTextColor); 252 | updated = true; 253 | } 254 | } 255 | else { 256 | if (this._playheadText) { 257 | this._playheadText.remove(); 258 | this._playheadText.destroy(); 259 | this._playheadText = null; 260 | updated = true; 261 | } 262 | } 263 | 264 | if (updated) { 265 | this._playheadLayer.draw(); 266 | } 267 | }; 268 | 269 | return PlayheadLayer; 270 | }); 271 | -------------------------------------------------------------------------------- /test/unit/waveform-builder-spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('./setup'); 4 | 5 | var WaveformBuilder = require('../../src/main/waveform/waveform-builder'); 6 | var WaveformData = require('waveform-data'); 7 | 8 | var TestAudioContext = window.AudioContext || window.mozAudioContext || window.webkitAudioContext; 9 | 10 | describe('WaveformBuilder', function() { 11 | describe('init', function() { 12 | it('should use the dataUriDefaultFormat value as a format URL if dataUri is provided as string', function(done) { 13 | var peaks = { 14 | options: { 15 | mediaElement: document.getElementById('media'), 16 | dataUri: 'base/test_data/sample.json' 17 | } 18 | }; 19 | 20 | var waveformBuilder = new WaveformBuilder(peaks); 21 | 22 | var spy = sinon.spy(waveformBuilder, '_createXHR'); 23 | 24 | waveformBuilder.init(peaks.options, function(err, waveformData) { 25 | expect(err).to.equal(null); 26 | expect(waveformData).to.be.an.instanceOf(WaveformData); 27 | 28 | var requestType = spy.getCall(0).args[1]; 29 | 30 | expect(requestType).to.equal('json'); 31 | 32 | done(); 33 | }); 34 | }); 35 | 36 | it('should return an error if the data handling fails', function(done) { 37 | var peaks = { 38 | options: { 39 | mediaElement: document.getElementById('media'), 40 | dataUri: 'base/test_data/404-file.json' 41 | } 42 | }; 43 | 44 | var waveformBuilder = new WaveformBuilder(peaks); 45 | 46 | waveformBuilder.init(peaks.options, function(err, waveformData) { 47 | expect(err).to.be.an.instanceOf(Error); 48 | expect(waveformData).to.not.be.ok; 49 | 50 | done(); 51 | }); 52 | }); 53 | 54 | it('should return an error if the data handling fails due to a network error', function(done) { 55 | var peaks = { 56 | options: { 57 | mediaElement: document.getElementById('media'), 58 | dataUri: 'file:///test.json' 59 | } 60 | }; 61 | 62 | var waveformBuilder = new WaveformBuilder(peaks); 63 | 64 | waveformBuilder.init(peaks.options, function(err, waveformData) { 65 | expect(err).to.be.an.instanceof(Error); 66 | expect(err.message).to.equal('XHR Failed'); 67 | expect(waveformData).to.equal(undefined); 68 | 69 | done(); 70 | }); 71 | }); 72 | 73 | it('should use the JSON dataUri connector', function(done) { 74 | var peaks = { 75 | options: { 76 | mediaElement: document.getElementById('media'), 77 | dataUri: { 78 | json: 'base/test_data/sample.json' 79 | } 80 | } 81 | }; 82 | 83 | var waveformBuilder = new WaveformBuilder(peaks); 84 | 85 | var spy = sinon.spy(waveformBuilder, '_createXHR'); 86 | 87 | waveformBuilder.init(peaks.options, function(err, waveformData) { 88 | expect(err).to.equal(null); 89 | expect(waveformData).to.be.an.instanceOf(WaveformData); 90 | 91 | var url = spy.getCall(0).args[0]; 92 | 93 | expect(url).to.equal(peaks.options.dataUri.json); 94 | 95 | done(); 96 | }); 97 | }); 98 | 99 | it('should not use credentials if withCredentials is not set', function(done) { 100 | var peaks = { 101 | options: { 102 | mediaElement: document.getElementById('media'), 103 | dataUri: { 104 | json: 'base/test_data/sample.json' 105 | } 106 | } 107 | }; 108 | 109 | var waveformBuilder = new WaveformBuilder(peaks); 110 | 111 | var spy = sinon.spy(waveformBuilder, '_createXHR'); 112 | 113 | waveformBuilder.init(peaks.options, function(err, waveformData) { 114 | expect(err).to.equal(null); 115 | expect(waveformData).to.be.an.instanceOf(WaveformData); 116 | 117 | var xhr = spy.getCall(0).returnValue; 118 | 119 | expect(xhr.withCredentials).to.equal(false); 120 | 121 | done(); 122 | }); 123 | }); 124 | 125 | it('should use credentials if withCredentials is set', function(done) { 126 | var peaks = { 127 | options: { 128 | mediaElement: document.getElementById('media'), 129 | withCredentials: true, 130 | dataUri: { 131 | json: 'base/test_data/sample.json' 132 | } 133 | } 134 | }; 135 | 136 | var waveformBuilder = new WaveformBuilder(peaks); 137 | 138 | var spy = sinon.spy(waveformBuilder, '_createXHR'); 139 | 140 | waveformBuilder.init(peaks.options, function(err, waveformData) { 141 | expect(err).to.equal(null); 142 | expect(waveformData).to.be.an.instanceOf(WaveformData); 143 | 144 | var xhr = spy.getCall(0).returnValue; 145 | 146 | expect(xhr.withCredentials).to.equal(true); 147 | 148 | done(); 149 | }); 150 | }); 151 | 152 | ('ArrayBuffer' in window) && it('should use the arraybuffer dataUri connector', function(done) { 153 | var peaks = { 154 | options: { 155 | mediaElement: document.getElementById('media'), 156 | dataUri: { 157 | arraybuffer: 'base/test_data/sample.dat' 158 | } 159 | } 160 | }; 161 | 162 | var waveformBuilder = new WaveformBuilder(peaks); 163 | 164 | var spy = sinon.spy(waveformBuilder, '_createXHR'); 165 | 166 | waveformBuilder.init(peaks.options, function(err, waveformData) { 167 | expect(err).to.equal(null); 168 | expect(waveformData).to.be.an.instanceOf(WaveformData); 169 | 170 | var url = spy.getCall(0).args[0]; 171 | 172 | expect(url).to.equal(peaks.options.dataUri.arraybuffer); 173 | 174 | done(); 175 | }); 176 | }); 177 | 178 | !('ArrayBuffer' in window) && it('should throw an exception if the only available format is browser incompatible', function() { 179 | expect(function() { 180 | var peaks = { 181 | options: { 182 | mediaElement: document.getElementById('media'), 183 | dataUri: { 184 | arraybuffer: 'base/test_data/sample.dat' 185 | } 186 | } 187 | }; 188 | 189 | var waveformBuilder = new WaveformBuilder(peaks); 190 | 191 | waveformBuilder.init(peaks.options, function() { 192 | }); 193 | }).to.throw(); 194 | }); 195 | 196 | it('should prefer binary waveform data over JSON', function(done) { 197 | var peaks = { 198 | options: { 199 | mediaElement: document.getElementById('media'), 200 | dataUri: { 201 | arraybuffer: 'base/test_data/sample.dat', 202 | json: 'base/test_data/sample.json' 203 | } 204 | } 205 | }; 206 | 207 | var waveformBuilder = new WaveformBuilder(peaks); 208 | 209 | var spy = sinon.spy(waveformBuilder, '_createXHR'); 210 | 211 | waveformBuilder.init(peaks.options, function(err, waveformData) { 212 | expect(err).to.equal(null); 213 | expect(waveformData).to.be.an.instanceOf(WaveformData); 214 | 215 | var url = spy.getCall(0).args[0]; 216 | var expectedDataUri = window.ArrayBuffer ? peaks.options.dataUri.arraybuffer : peaks.options.dataUri.json; 217 | 218 | expect(url).to.equal(expectedDataUri); 219 | 220 | done(); 221 | }); 222 | }); 223 | 224 | it('should build using WebAudio if the API is available and audioContext is provided', function(done) { 225 | var peaks = { 226 | options: { 227 | mediaElement: document.getElementById('media'), 228 | webAudio: { 229 | audioContext: new TestAudioContext(), 230 | scale: 512 231 | }, 232 | zoomLevels: [512, 1024, 2048, 4096] 233 | }, 234 | player: { 235 | getCurrentSource: sinon.stub().returns(document.getElementById('media').currentSrc) 236 | } 237 | }; 238 | 239 | var waveformBuilder = new WaveformBuilder(peaks); 240 | 241 | waveformBuilder.init(peaks.options, function(err, waveformData) { 242 | expect(err).to.equal(null); 243 | expect(waveformData).to.be.an.instanceOf(WaveformData); 244 | done(); 245 | }); 246 | }); 247 | }); 248 | }); 249 | -------------------------------------------------------------------------------- /test/unit/api-views-spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('./setup'); 4 | 5 | var Peaks = require('../../src/main'); 6 | var WaveformOverview = require('../../src/main/views/waveform.overview'); 7 | var WaveformZoomView = require('../../src/main/views/waveform.zoomview'); 8 | 9 | describe('Peaks.views', function() { 10 | var p; 11 | 12 | var overviewContainer = null; 13 | var zoomviewContainer = null; 14 | 15 | beforeEach(function() { 16 | overviewContainer = document.createElement('div'); 17 | overviewContainer.style.width = '400px'; 18 | overviewContainer.style.height = '100px'; 19 | document.body.appendChild(overviewContainer); 20 | 21 | zoomviewContainer = document.createElement('div'); 22 | zoomviewContainer.style.width = '400px'; 23 | zoomviewContainer.style.height = '100px'; 24 | document.body.appendChild(zoomviewContainer); 25 | }); 26 | 27 | afterEach(function() { 28 | document.body.removeChild(overviewContainer); 29 | document.body.removeChild(zoomviewContainer); 30 | }); 31 | 32 | afterEach(function() { 33 | if (p) { 34 | p.destroy(); 35 | } 36 | }); 37 | 38 | describe('createZoomview', function() { 39 | context('with existing zoomview', function() { 40 | beforeEach(function(done) { 41 | p = Peaks.init({ 42 | containers: { 43 | zoomview: zoomviewContainer 44 | }, 45 | mediaElement: document.getElementById('media'), 46 | dataUri: { 47 | json: 'base/test_data/sample.json' 48 | } 49 | }); 50 | 51 | p.on('peaks.ready', done); 52 | }); 53 | 54 | it('should return the existing zoomview instance', function() { 55 | var view = p.views.getView('zoomview'); 56 | 57 | expect(view).to.be.an.instanceOf(WaveformZoomView); 58 | 59 | expect(p.views.createZoomview(zoomviewContainer)).to.equal(view); 60 | }); 61 | }); 62 | 63 | context('without existing zoomview', function() { 64 | beforeEach(function(done) { 65 | p = Peaks.init({ 66 | containers: { 67 | overview: overviewContainer 68 | }, 69 | mediaElement: document.getElementById('media'), 70 | dataUri: { 71 | json: 'base/test_data/sample.json' 72 | } 73 | }); 74 | 75 | p.on('peaks.ready', done); 76 | }); 77 | 78 | it('should return a new zoomview instance', function() { 79 | expect(p.views.getView('zoomview')).to.equal(null); 80 | 81 | var view = p.views.createZoomview(zoomviewContainer); 82 | 83 | expect(view).to.be.an.instanceOf(WaveformZoomView); 84 | 85 | expect(p.views.getView('zoomview')).to.equal(view); 86 | }); 87 | }); 88 | }); 89 | 90 | describe('createOverview', function() { 91 | context('with existing overview', function() { 92 | beforeEach(function(done) { 93 | p = Peaks.init({ 94 | containers: { 95 | overview: overviewContainer 96 | }, 97 | mediaElement: document.getElementById('media'), 98 | dataUri: { 99 | json: 'base/test_data/sample.json' 100 | } 101 | }); 102 | 103 | p.on('peaks.ready', done); 104 | }); 105 | 106 | it('should return the existing overview instance', function() { 107 | var view = p.views.getView('overview'); 108 | 109 | expect(view).to.be.an.instanceOf(WaveformOverview); 110 | 111 | expect(p.views.createOverview(overviewContainer)).to.equal(view); 112 | }); 113 | }); 114 | 115 | context('without existing overview', function() { 116 | beforeEach(function(done) { 117 | p = Peaks.init({ 118 | containers: { 119 | zoomview: zoomviewContainer 120 | }, 121 | mediaElement: document.getElementById('media'), 122 | dataUri: { 123 | json: 'base/test_data/sample.json' 124 | } 125 | }); 126 | 127 | p.on('peaks.ready', done); 128 | }); 129 | 130 | it('should return a new overview instance', function() { 131 | expect(p.views.getView('overview')).to.equal(null); 132 | 133 | var view = p.views.createOverview(overviewContainer); 134 | 135 | expect(view).to.be.an.instanceOf(WaveformOverview); 136 | 137 | expect(p.views.getView('overview')).to.equal(view); 138 | }); 139 | }); 140 | }); 141 | 142 | describe('getView', function() { 143 | context('with zoomview and overview containers', function() { 144 | beforeEach(function(done) { 145 | p = Peaks.init({ 146 | containers: { 147 | zoomview: zoomviewContainer, 148 | overview: overviewContainer 149 | }, 150 | mediaElement: document.getElementById('media'), 151 | dataUri: { 152 | json: 'base/test_data/sample.json' 153 | } 154 | }); 155 | 156 | p.on('peaks.ready', done); 157 | }); 158 | 159 | it('should return the zoomview', function() { 160 | var view = p.views.getView('zoomview'); 161 | expect(view).to.be.an.instanceOf(WaveformZoomView); 162 | }); 163 | 164 | it('should return the overview', function() { 165 | var view = p.views.getView('overview'); 166 | expect(view).to.be.an.instanceOf(WaveformOverview); 167 | }); 168 | 169 | it('should return null if given no view name', function() { 170 | var view = p.views.getView(); 171 | expect(view).to.equal(null); 172 | }); 173 | 174 | it('should return null if given an invalid view name', function() { 175 | var view = p.views.getView('unknown'); 176 | expect(view).to.equal(null); 177 | }); 178 | }); 179 | 180 | context('with only a zoomview container', function() { 181 | beforeEach(function(done) { 182 | p = Peaks.init({ 183 | containers: { 184 | zoomview: zoomviewContainer 185 | }, 186 | mediaElement: document.getElementById('media'), 187 | dataUri: { 188 | json: 'base/test_data/sample.json' 189 | } 190 | }); 191 | 192 | p.on('peaks.ready', done); 193 | }); 194 | 195 | it('should return the zoomview', function() { 196 | var view = p.views.getView('zoomview'); 197 | expect(view).to.be.an.instanceOf(WaveformZoomView); 198 | }); 199 | 200 | it('should return null if given the overview view name', function() { 201 | var view = p.views.getView('overview'); 202 | expect(view).to.equal(null); 203 | }); 204 | 205 | it('should return the zoomview if given no view name', function() { 206 | var view = p.views.getView(); 207 | expect(view).to.be.an.instanceOf(WaveformZoomView); 208 | }); 209 | 210 | it('should return null if given an invalid view name', function() { 211 | var view = p.views.getView('unknown'); 212 | expect(view).to.equal(null); 213 | }); 214 | }); 215 | 216 | context('with only an overview container', function() { 217 | beforeEach(function(done) { 218 | p = Peaks.init({ 219 | containers: { 220 | overview: overviewContainer 221 | }, 222 | mediaElement: document.getElementById('media'), 223 | dataUri: { 224 | json: 'base/test_data/sample.json' 225 | } 226 | }); 227 | 228 | p.on('peaks.ready', done); 229 | }); 230 | 231 | it('should return null if given the zoomview view name', function() { 232 | var view = p.views.getView('zoomview'); 233 | expect(view).to.equal(null); 234 | }); 235 | 236 | it('should return the overview', function() { 237 | var view = p.views.getView('overview'); 238 | expect(view).to.be.an.instanceOf(WaveformOverview); 239 | }); 240 | 241 | it('should return the overview if given no view name', function() { 242 | var view = p.views.getView(); 243 | expect(view).to.be.an.instanceOf(WaveformOverview); 244 | }); 245 | 246 | it('should return null if given an invalid view name', function() { 247 | var view = p.views.getView('unknown'); 248 | expect(view).to.equal(null); 249 | }); 250 | }); 251 | }); 252 | }); 253 | -------------------------------------------------------------------------------- /peaks.js.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Peaks.js TypeScript Definitions 3 | * @author Evan Louie (https://evanlouie.com) 4 | */ 5 | 6 | declare module 'peaks.js' { 7 | 8 | interface SegmentUpdateOptions { 9 | startTime?: number; 10 | endTime?: number; 11 | editable?: boolean; 12 | color?: string; 13 | labelText?: string; 14 | } 15 | 16 | interface Segment { 17 | startTime: number; 18 | endTime: number; 19 | editable?: boolean; 20 | color?: string; 21 | labelText?: string; 22 | id?: string; 23 | update: (options: SegmentUpdateOptions) => void; 24 | } 25 | 26 | interface PointUpdateOptions { 27 | time?: number; 28 | editable?: boolean; 29 | color?: string; 30 | labelText?: string; 31 | } 32 | 33 | interface Point { 34 | time: number; 35 | editable?: boolean; 36 | color?: string; 37 | labelText?: string; 38 | id?: string; 39 | update: (options: PointUpdateOptions) => void; 40 | } 41 | 42 | interface RequiredOptions { 43 | // HTML5 Media element containing an audio track 44 | mediaElement: Element; 45 | } 46 | 47 | interface SingleContainerOptions { 48 | // Container element for the waveform views 49 | container: HTMLElement; 50 | } 51 | 52 | interface ViewContainerOptions { 53 | containers: { 54 | // Container element for the overview (non-zoomable) waveform view 55 | overview?: HTMLElement; 56 | // Container element for the zoomable waveform view 57 | zoomview?: HTMLElement; 58 | } 59 | } 60 | 61 | type ContainerOptions = SingleContainerOptions | ViewContainerOptions; 62 | 63 | interface PreGeneratedWaveformOptions { 64 | dataUri: { 65 | // URI to waveform data file in binary or JSON 66 | arraybuffer?: string; 67 | json?: string; 68 | } 69 | } 70 | 71 | interface WebAudioOptions { 72 | webAudio: { 73 | // A Web Audio AudioContext instance which can be used 74 | // to render the waveform if dataUri is not provided 75 | audioContext?: AudioContext; 76 | // Alternatively, provide an AudioBuffer containing the decoded audio 77 | // samples. In this case, an AudioContext is not needed 78 | audioBuffer?: AudioBuffer; 79 | scale?: number; 80 | multiChannel?: boolean; 81 | } 82 | } 83 | 84 | type AudioOptions = WebAudioOptions | PreGeneratedWaveformOptions; 85 | 86 | interface OptionalOptions { 87 | // If true, peaks will send credentials with all network requests 88 | // i.e. when fetching waveform data. 89 | withCredentials?: boolean; 90 | // async logging function 91 | logger?: (...args: any[]) => void; 92 | // default height of the waveform canvases in pixels 93 | height?: number; 94 | // Array of zoom levels in samples per pixel (big >> small) 95 | zoomLevels?: number[]; 96 | // Bind keyboard controls 97 | keyboard?: boolean; 98 | // Keyboard nudge increment in seconds (left arrow/right arrow) 99 | nudgeIncrement?: number; 100 | // Colour for the in marker of segments 101 | inMarkerColor?: string; 102 | // Colour for the out marker of segments 103 | outMarkerColor?: string; 104 | // Colour for the zoomed in waveform 105 | zoomWaveformColor?: string; 106 | // Colour for the overview waveform 107 | overviewWaveformColor?: string; 108 | // Colour for the overview waveform rectangle 109 | // that shows what the zoom view shows 110 | overviewHighlightRectangleColor?: string; 111 | // Colour for segments on the waveform 112 | segmentColor?: string; 113 | // Colour of the play head 114 | playheadColor?: string; 115 | // Colour of the play head text 116 | playheadTextColor?: string; 117 | // Show current time next to the play head 118 | // (zoom view only) 119 | showPlayheadTime?: boolean; 120 | // the color of a point marker 121 | pointMarkerColor?: string; 122 | // Colour of the axis gridlines 123 | axisGridlineColor?: string; 124 | // Colour of the axis labels 125 | axisLabelColor?: string; 126 | // Random colour per segment (overrides segmentColor) 127 | randomizeSegmentColor?: boolean; 128 | // Zoom view adapter to use. Valid adapters are: 129 | // 'animated' (default) and 'static' 130 | zoomAdapter?: string; 131 | // Array of initial segment objects 132 | segments?: Segment[]; 133 | // Array of initial point objects 134 | points?: Point[]; 135 | // Emit cue events when playing 136 | emitCueEvents?: boolean; 137 | } 138 | 139 | interface SetSourceRequiredOptions { 140 | mediaUrl: string; 141 | withCredentials?: boolean; 142 | } 143 | 144 | type SetSourceOptions = SetSourceRequiredOptions & AudioOptions; 145 | 146 | type SetSourceCallback = (error: Error) => any; 147 | 148 | interface InstanceEvents { 149 | 'peaks.ready': () => any; 150 | 'points.add': (points: Point[]) => any; 151 | 'points.dblclick': (point: Point) => any; 152 | 'points.dragend': (point: Point) => any; 153 | 'points.dragmove': (point: Point) => any; 154 | 'points.dragstart': (point: Point) => any; 155 | 'points.mouseenter': (point: Point) => any; 156 | 'points.mouseleave': (point: Point) => any; 157 | 'points.remove_all': () => any; 158 | 'points.remove': (points: Point[]) => any; 159 | 'points.enter': (point: Point) => any; 160 | 'segments.add': (segments: Segment[]) => any; 161 | 'segments.dragged': (segment: Segment) => any; 162 | 'segments.remove_all': () => any; 163 | 'segments.remove': (segments: Segment[]) => any; 164 | 'segments.mouseenter': (segment: Segment) => any; 165 | 'segments.mouseleave': (segment: Segment) => any; 166 | 'segments.click': (segment: Segment) => any; 167 | 'segments.enter': (segment: Segment) => any; 168 | 'segments.exit': (segment: Segment) => any; 169 | 'zoom.update': (currentZoomLevel: number, previousZoomLevel: number) => any; 170 | player_seek: (time: number) => any; 171 | user_seek: (time: number) => any; 172 | } 173 | 174 | interface WaveformView { 175 | setAmplitudeScale: (scale: number) => void; 176 | setWaveformColor: (color: string) => void; 177 | showPlayheadTime: (show: boolean) => void; 178 | enableAutoScroll: (enable: boolean) => void; 179 | } 180 | 181 | interface PeaksInstance { 182 | setSource: (options: SetSourceOptions, callback: SetSourceCallback) => void; 183 | destroy: () => void; 184 | // Player API 185 | player: { 186 | play: () => void; 187 | pause: () => void; 188 | getCurrentTime: () => number; 189 | seek: (time: number) => void; 190 | playSegment: (segment: Segment) => void; 191 | }; 192 | // Views API 193 | views: { 194 | createOverview: (container: HTMLElement) => WaveformView; 195 | createZoomview: (container: HTMLElement) => WaveformView; 196 | getView: (name: string) => WaveformView | null; 197 | }; 198 | // Zoom API 199 | zoom: { 200 | zoomOut: () => void; 201 | zoomIn: () => void; 202 | setZoom: (index: number) => void; 203 | getZoom: () => number; 204 | }; 205 | // Segments API 206 | segments: { 207 | add: (segments: Segment | Segment[]) => void; 208 | getSegments: () => Segment[]; 209 | getSegment: (id: string) => Segment | null; 210 | removeByTime: (startTime: number, endTime?: number) => Segment[]; 211 | removeById: (segmentId: string) => Segment[]; 212 | removeAll: () => void; 213 | }; 214 | // Points API 215 | points: { 216 | add: (points: Point | Point[]) => void; 217 | getPoints: () => Point[]; 218 | getPoint: (id: string) => Point | null; 219 | removeByTime: (time: number) => Point[]; 220 | removeById: (id: string) => Point[]; 221 | removeAll: () => void; 222 | }; 223 | // Events 224 | on: (event: E, listener: InstanceEvents[E]) => void; 225 | } 226 | 227 | type PeaksInitCallback = (error: Error, peaks?: PeaksInstance) => any; 228 | 229 | interface PeaksOptionsWithoutAudioOptions extends RequiredOptions, OptionalOptions {} 230 | 231 | export type PeaksOptions = PeaksOptionsWithoutAudioOptions & AudioOptions & ContainerOptions; 232 | export function init(options: PeaksOptions, callback?: PeaksInitCallback): PeaksInstance; 233 | } 234 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | -------------------------------------------------------------------------------- /src/main/markers/waveform.points.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 3 | * 4 | * Defines the {@link WaveformPoints} class. 5 | * 6 | * @module peaks/markers/waveform.points 7 | */ 8 | 9 | define([ 10 | 'peaks/markers/point', 11 | 'peaks/waveform/waveform.utils' 12 | ], function(Point, Utils) { 13 | 'use strict'; 14 | 15 | /** 16 | * Point parameters. 17 | * 18 | * @typedef {Object} PointOptions 19 | * @global 20 | * @property {Number} point Point time, in seconds. 21 | * @property {Boolean=} editable If true the point time can be 22 | * adjusted via the user interface. 23 | * Default: false. 24 | * @property {String=} color Point marker color. 25 | * Default: a random color. 26 | * @property {String=} labelText Point label text. 27 | * Default: an empty string. 28 | * @property {String=} id A unique point identifier. 29 | * Default: an automatically generated identifier. 30 | */ 31 | 32 | /** 33 | * Handles all functionality related to the adding, removing and manipulation 34 | * of points. A point is a single instant of time. 35 | * 36 | * @class 37 | * @alias WaveformPoints 38 | * 39 | * @param {Peaks} peaks The parent Peaks object. 40 | */ 41 | 42 | function WaveformPoints(peaks) { 43 | this._peaks = peaks; 44 | this._points = []; 45 | this._pointsById = {}; 46 | this._pointIdCounter = 0; 47 | } 48 | 49 | /** 50 | * Returns a new unique point id value. 51 | * 52 | * @returns {String} 53 | */ 54 | 55 | WaveformPoints.prototype._getNextPointId = function() { 56 | return 'peaks.point.' + this._pointIdCounter++; 57 | }; 58 | 59 | /** 60 | * Adds a new point object. 61 | * 62 | * @private 63 | * @param {Point} point 64 | */ 65 | 66 | WaveformPoints.prototype._addPoint = function(point) { 67 | this._points.push(point); 68 | 69 | this._pointsById[point.id] = point; 70 | }; 71 | 72 | /** 73 | * Creates a new point object. 74 | * 75 | * @private 76 | * @param {PointOptions} options 77 | * @returns {Point} 78 | */ 79 | 80 | WaveformPoints.prototype._createPoint = function(options) { 81 | if (Object.prototype.hasOwnProperty.call(options, 'timestamp') || 82 | !Object.prototype.hasOwnProperty.call(options, 'time')) { 83 | // eslint-disable-next-line max-len 84 | this._peaks.options.deprecationLogger("peaks.points.add(): The 'timestamp' attribute is deprecated; use 'time' instead"); 85 | options.time = options.timestamp; 86 | } 87 | 88 | var point = new Point( 89 | this, 90 | Utils.isNullOrUndefined(options.id) ? this._getNextPointId() : options.id, 91 | options.time, 92 | options.labelText, 93 | options.color, 94 | Boolean(options.editable) 95 | ); 96 | 97 | return point; 98 | }; 99 | 100 | /** 101 | * Returns all points. 102 | * 103 | * @returns {Array} 104 | */ 105 | 106 | WaveformPoints.prototype.getPoints = function() { 107 | return this._points; 108 | }; 109 | 110 | /** 111 | * Returns the point with the given id, or null if not found. 112 | * 113 | * @param {String} id 114 | * @returns {Point|null} 115 | */ 116 | 117 | WaveformPoints.prototype.getPoint = function(id) { 118 | return this._pointsById[id] || null; 119 | }; 120 | 121 | /** 122 | * Returns all points within a given time region. 123 | * 124 | * @param {Number} startTime The start of the time region, in seconds. 125 | * @param {Number} endTime The end of the time region, in seconds. 126 | * @returns {Array} 127 | */ 128 | 129 | WaveformPoints.prototype.find = function(startTime, endTime) { 130 | return this._points.filter(function(point) { 131 | return point.isVisible(startTime, endTime); 132 | }); 133 | }; 134 | 135 | /** 136 | * Adds one or more points to the timeline. 137 | * 138 | * @param {PointOptions|Array} pointOrPoints 139 | */ 140 | 141 | WaveformPoints.prototype.add = function(pointOrPoints) { 142 | var self = this; 143 | 144 | var points = Array.isArray(arguments[0]) ? 145 | arguments[0] : 146 | Array.prototype.slice.call(arguments); 147 | 148 | if (typeof points[0] === 'number') { 149 | // eslint-disable-next-line max-len 150 | this._peaks.options.deprecationLogger('peaks.points.add(): expected a segment object or an array'); 151 | 152 | points = [{ 153 | time: arguments[0], 154 | editable: arguments[1], 155 | color: arguments[2], 156 | labelText: arguments[3] 157 | }]; 158 | } 159 | 160 | points = points.map(function(pointOptions) { 161 | var point = self._createPoint(pointOptions); 162 | 163 | if (Object.prototype.hasOwnProperty.call(self._pointsById, point.id)) { 164 | throw new Error('peaks.points.add(): duplicate id'); 165 | } 166 | 167 | return point; 168 | }); 169 | 170 | points.forEach(function(point) { 171 | self._addPoint(point); 172 | }); 173 | 174 | this._peaks.emit('points.add', points); 175 | }; 176 | 177 | /** 178 | * Returns the indexes of points that match the given predicate. 179 | * 180 | * @private 181 | * @param {Function} predicate Predicate function to find matching points. 182 | * @returns {Array} An array of indexes into the points array of 183 | * the matching elements. 184 | */ 185 | 186 | WaveformPoints.prototype._findPoint = function(predicate) { 187 | var indexes = []; 188 | 189 | for (var i = 0, length = this._points.length; i < length; i++) { 190 | if (predicate(this._points[i])) { 191 | indexes.push(i); 192 | } 193 | } 194 | 195 | return indexes; 196 | }; 197 | 198 | /** 199 | * Removes the points at the given array indexes. 200 | * 201 | * @private 202 | * @param {Array} indexes The array indexes to remove. 203 | * @returns {Array} The removed {@link Point} objects. 204 | */ 205 | 206 | WaveformPoints.prototype._removeIndexes = function(indexes) { 207 | var removed = []; 208 | 209 | for (var i = 0; i < indexes.length; i++) { 210 | var index = indexes[i] - removed.length; 211 | 212 | var itemRemoved = this._points.splice(index, 1)[0]; 213 | 214 | delete this._pointsById[itemRemoved.id]; 215 | 216 | removed.push(itemRemoved); 217 | } 218 | 219 | return removed; 220 | }; 221 | 222 | /** 223 | * Removes all points that match a given predicate function. 224 | * 225 | * After removing the points, this function emits a 226 | * points.remove event with the removed {@link Point} 227 | * objects. 228 | * 229 | * @private 230 | * @param {Function} predicate A predicate function that identifies which 231 | * points to remove. 232 | * @returns {Array} The removed {@link Points} objects. 233 | */ 234 | 235 | WaveformPoints.prototype._removePoints = function(predicate) { 236 | var indexes = this._findPoint(predicate); 237 | 238 | var removed = this._removeIndexes(indexes); 239 | 240 | this._peaks.emit('points.remove', removed); 241 | 242 | return removed; 243 | }; 244 | 245 | /** 246 | * Removes the given point. 247 | * 248 | * @param {Point} point The point to remove. 249 | * @returns {Array} The removed points. 250 | */ 251 | 252 | WaveformPoints.prototype.remove = function(point) { 253 | return this._removePoints(function(p) { 254 | return p === point; 255 | }); 256 | }; 257 | 258 | /** 259 | * Removes any points with the given id. 260 | * 261 | * @param {String} id 262 | * @returns {Array} The removed {@link Point} objects. 263 | */ 264 | 265 | WaveformPoints.prototype.removeById = function(pointId) { 266 | return this._removePoints(function(point) { 267 | return point.id === pointId; 268 | }); 269 | }; 270 | 271 | /** 272 | * Removes any points at the given time. 273 | * 274 | * @param {Number} time 275 | * @returns {Array} The removed {@link Point} objects. 276 | */ 277 | 278 | WaveformPoints.prototype.removeByTime = function(time) { 279 | return this._removePoints(function(point) { 280 | return point.time === time; 281 | }); 282 | }; 283 | 284 | /** 285 | * Removes all points. 286 | * 287 | * After removing the points, this function emits a 288 | * points.remove_all event. 289 | */ 290 | 291 | WaveformPoints.prototype.removeAll = function() { 292 | this._points = []; 293 | this._pointsById = {}; 294 | this._peaks.emit('points.remove_all'); 295 | }; 296 | 297 | return WaveformPoints; 298 | }); 299 | -------------------------------------------------------------------------------- /demo/multi-channel.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Peaks.js Demo Page 6 | 83 | 84 | 85 |
86 |

Peaks.js

87 |

88 | Peaks.js is a JavaScript library that allows you to display and 89 | interaction with audio waveforms in the browser. 90 |

91 |

92 | It was developed by BBC R&D 93 | to allow audio editors to make accurate clippings of audio content. 94 | You can read more about the project 95 | here. 96 |

97 | 98 |

Demo pages

99 |

100 | The following pages demonstrate various configuration options: 101 |

102 |

103 | Precomputed Waveform Data | 104 | Web Audio API | 105 | Single Zoomable Waveform | 106 | Single Fixed Waveform | 107 | Cue Events | 108 | Changing the Media URL | 109 | Multi-Channel Waveform 110 |

111 |

Demo: Multi-Channel Waveform

112 |

113 | This demo shows how to show a stereo waveform. Use the Select source 114 | control to switch between pre-computed waveform data, fetched from the 115 | web server, and waveform data generated in the browser using the Web 116 | Audio API. 117 |

118 |

119 | The pre-computed waveform data was produced using 120 | audiowaveform, with 121 | the following options: 122 |

123 |

124 | audiowaveform -i 07023003.mp3 -o 07023003-2channel.dat -b 8 --split-channels 125 |

126 |

127 | Audio content is copyright BBC, from the BBC Sound Effects 128 | library, used under the terms of the 129 | RemArc Licence. 130 |

131 |
132 | 133 |
134 |
135 |
136 |
137 | 138 |
139 | 143 | 144 |
145 | 146 | 147 | 148 | 149 | 150 | 151 |
152 | 153 | 154 | 155 | 156 |
157 |
158 |
159 | 160 | 161 | 266 | 267 | 268 | -------------------------------------------------------------------------------- /src/main/views/points-layer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 3 | * 4 | * Defines the {@link PointsLayer} class. 5 | * 6 | * @module peaks/views/points-layer 7 | */ 8 | 9 | define([ 10 | 'peaks/waveform/waveform.utils', 11 | 'konva' 12 | ], function(Utils, Konva) { 13 | 'use strict'; 14 | 15 | /** 16 | * Creates a Konva.Layer that displays point markers against the audio 17 | * waveform. 18 | * 19 | * @class 20 | * @alias PointsLayer 21 | * 22 | * @param {Peaks} peaks 23 | * @param {WaveformOverview|WaveformZoomView} view 24 | * @param {Boolean} allowEditing 25 | * @param {Boolean} showLabels 26 | */ 27 | 28 | function PointsLayer(peaks, view, allowEditing, showLabels) { 29 | this._peaks = peaks; 30 | this._view = view; 31 | this._allowEditing = allowEditing; 32 | this._showLabels = showLabels; 33 | this._pointGroups = {}; 34 | this._layer = new Konva.Layer(); 35 | 36 | this._registerEventHandlers(); 37 | } 38 | 39 | /** 40 | * Adds the layer to the given {Konva.Stage}. 41 | * 42 | * @param {Konva.Stage} stage 43 | */ 44 | 45 | PointsLayer.prototype.addToStage = function(stage) { 46 | stage.add(this._layer); 47 | }; 48 | 49 | PointsLayer.prototype._registerEventHandlers = function() { 50 | var self = this; 51 | 52 | this._peaks.on('points.update', function(point) { 53 | var frameOffset = self._view.getFrameOffset(); 54 | var width = self._view.getWidth(); 55 | var frameStartTime = self._view.pixelsToTime(frameOffset); 56 | var frameEndTime = self._view.pixelsToTime(frameOffset + width); 57 | 58 | self._removePoint(point); 59 | if (point.isVisible(frameStartTime, frameEndTime)) { 60 | self._addPointGroup(point); 61 | } 62 | 63 | self.updatePoints(frameStartTime, frameEndTime); 64 | }); 65 | 66 | this._peaks.on('points.add', function(points) { 67 | var frameOffset = self._view.getFrameOffset(); 68 | var width = self._view.getWidth(); 69 | 70 | var frameStartTime = self._view.pixelsToTime(frameOffset); 71 | var frameEndTime = self._view.pixelsToTime(frameOffset + width); 72 | 73 | points.forEach(function(point) { 74 | if (point.isVisible(frameStartTime, frameEndTime)) { 75 | self._addPointGroup(point); 76 | } 77 | }); 78 | 79 | self.updatePoints(frameStartTime, frameEndTime); 80 | }); 81 | 82 | this._peaks.on('points.remove', function(points) { 83 | points.forEach(function(point) { 84 | self._removePoint(point); 85 | }); 86 | 87 | self._layer.draw(); 88 | }); 89 | 90 | this._peaks.on('points.remove_all', function() { 91 | self._layer.removeChildren(); 92 | self._pointGroups = {}; 93 | 94 | self._layer.draw(); 95 | }); 96 | 97 | var pointDragHandler = this._pointDragHandler.bind(this); 98 | 99 | this._peaks.on('points.dragstart', pointDragHandler); 100 | this._peaks.on('points.dragmove', pointDragHandler); 101 | this._peaks.on('points.dragend', pointDragHandler); 102 | }; 103 | 104 | PointsLayer.prototype._pointDragHandler = function(point) { 105 | this._updatePoint(point); 106 | this._layer.draw(); 107 | }; 108 | 109 | /** 110 | * Creates the Konva UI objects for a given point. 111 | * 112 | * @private 113 | * @param {Point} point 114 | * @returns {Konva.Group} 115 | */ 116 | 117 | PointsLayer.prototype._createPointGroup = function(point) { 118 | var pointGroup = new Konva.Group(); 119 | 120 | pointGroup.point = point; 121 | 122 | var editable = this._allowEditing && point.editable; 123 | 124 | pointGroup.marker = this._peaks.options.createPointMarker({ 125 | draggable: editable, 126 | showLabel: this._showLabels, 127 | handleColor: point.color ? point.color : this._peaks.options.pointMarkerColor, 128 | height: this._view.getHeight(), 129 | pointGroup: pointGroup, 130 | point: point, 131 | layer: this._layer, 132 | onDblClick: this._onPointHandleDblClick.bind(this), 133 | onDragStart: this._onPointHandleDragStart.bind(this), 134 | onDragMove: this._onPointHandleDragMove.bind(this), 135 | onDragEnd: this._onPointHandleDragEnd.bind(this), 136 | onMouseEnter: this._onPointHandleMouseEnter.bind(this), 137 | onMouseLeave: this._onPointHandleMouseLeave.bind(this) 138 | }); 139 | 140 | pointGroup.add(pointGroup.marker); 141 | 142 | return pointGroup; 143 | }; 144 | 145 | /** 146 | * Adds a Konva UI object to the layer for a given point. 147 | * 148 | * @private 149 | * @param {Point} point 150 | * @returns {Konva.Group} 151 | */ 152 | 153 | PointsLayer.prototype._addPointGroup = function(point) { 154 | var pointGroup = this._createPointGroup(point); 155 | 156 | this._pointGroups[point.id] = pointGroup; 157 | 158 | this._layer.add(pointGroup); 159 | 160 | return pointGroup; 161 | }; 162 | 163 | /** 164 | * @param {Point} point 165 | */ 166 | 167 | PointsLayer.prototype._onPointHandleDragMove = function(point) { 168 | var pointGroup = this._pointGroups[point.id]; 169 | 170 | var markerX = pointGroup.marker.getX(); 171 | 172 | if (markerX > 0 && markerX < this._view.getWidth()) { 173 | var offset = this._view.getFrameOffset() + 174 | markerX + 175 | pointGroup.marker.getWidth(); 176 | 177 | point.time = this._view.pixelsToTime(offset); 178 | } 179 | 180 | this._peaks.emit('points.dragmove', point); 181 | }; 182 | 183 | /** 184 | * @param {Point} point 185 | */ 186 | 187 | PointsLayer.prototype._onPointHandleMouseEnter = function(point) { 188 | this._peaks.emit('points.mouseenter', point); 189 | }; 190 | 191 | /** 192 | * @param {Point} point 193 | */ 194 | 195 | PointsLayer.prototype._onPointHandleMouseLeave = function(point) { 196 | this._peaks.emit('points.mouseleave', point); 197 | }; 198 | 199 | /** 200 | * @param {Point} point 201 | */ 202 | 203 | PointsLayer.prototype._onPointHandleDblClick = function(point) { 204 | this._peaks.emit('points.dblclick', point); 205 | }; 206 | 207 | /** 208 | * @param {Point} point 209 | */ 210 | 211 | PointsLayer.prototype._onPointHandleDragStart = function(point) { 212 | this._peaks.emit('points.dragstart', point); 213 | }; 214 | 215 | /** 216 | * @param {Point} point 217 | */ 218 | 219 | PointsLayer.prototype._onPointHandleDragEnd = function(point) { 220 | this._peaks.emit('points.dragend', point); 221 | }; 222 | 223 | /** 224 | * Updates the positions of all displayed points in the view. 225 | * 226 | * @param {Number} startTime The start of the visible range in the view, 227 | * in seconds. 228 | * @param {Number} endTime The end of the visible range in the view, 229 | * in seconds. 230 | */ 231 | 232 | PointsLayer.prototype.updatePoints = function(startTime, endTime) { 233 | // Update all points in the visible time range. 234 | var points = this._peaks.points.find(startTime, endTime); 235 | 236 | var count = points.length; 237 | 238 | points.forEach(this._updatePoint.bind(this)); 239 | 240 | // TODO: in the overview all segments are visible, so no need to check 241 | count += this._removeInvisiblePoints(startTime, endTime); 242 | 243 | if (count > 0) { 244 | this._layer.draw(); 245 | } 246 | }; 247 | 248 | /** 249 | * @private 250 | * @param {Point} point 251 | */ 252 | 253 | PointsLayer.prototype._updatePoint = function(point) { 254 | var pointGroup = this._findOrAddPointGroup(point); 255 | 256 | // Point is visible 257 | var timestampOffset = this._view.timeToPixels(point.time); 258 | 259 | var startPixel = timestampOffset - this._view.getFrameOffset(); 260 | 261 | if (pointGroup.marker) { 262 | pointGroup.marker.setX(startPixel); 263 | 264 | if (pointGroup.marker.time) { 265 | pointGroup.marker.time.setText(Utils.formatTime(point.time, false)); 266 | } 267 | } 268 | }; 269 | 270 | /** 271 | * @private 272 | * @param {Point} point 273 | */ 274 | 275 | PointsLayer.prototype._findOrAddPointGroup = function(point) { 276 | var pointGroup = this._pointGroups[point.id]; 277 | 278 | if (!pointGroup) { 279 | pointGroup = this._addPointGroup(point); 280 | } 281 | 282 | return pointGroup; 283 | }; 284 | 285 | /** 286 | * Remove any points that are not visible, i.e., are outside the given time 287 | * range. 288 | * 289 | * @private 290 | * @param {Number} startTime The start of the visible time range, in seconds. 291 | * @param {Number} endTime The end of the visible time range, in seconds. 292 | * @returns {Number} The number of points removed. 293 | */ 294 | 295 | PointsLayer.prototype._removeInvisiblePoints = function(startTime, endTime) { 296 | var count = 0; 297 | 298 | for (var pointId in this._pointGroups) { 299 | if (Object.prototype.hasOwnProperty.call(this._pointGroups, pointId)) { 300 | var point = this._pointGroups[pointId].point; 301 | 302 | if (!point.isVisible(startTime, endTime)) { 303 | this._removePoint(point); 304 | count++; 305 | } 306 | } 307 | } 308 | 309 | return count; 310 | }; 311 | 312 | /** 313 | * Removes the UI object for a given point. 314 | * 315 | * @private 316 | * @param {Point} point 317 | */ 318 | 319 | PointsLayer.prototype._removePoint = function(point) { 320 | var pointGroup = this._pointGroups[point.id]; 321 | 322 | if (pointGroup) { 323 | pointGroup.destroyChildren(); 324 | pointGroup.destroy(); 325 | delete this._pointGroups[point.id]; 326 | } 327 | }; 328 | 329 | /** 330 | * Toggles visibility of the points layer. 331 | * 332 | * @param {Boolean} visible 333 | */ 334 | 335 | PointsLayer.prototype.setVisible = function(visible) { 336 | this._layer.setVisible(visible); 337 | }; 338 | 339 | return PointsLayer; 340 | }); 341 | -------------------------------------------------------------------------------- /src/main/cues/cue-emitter.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 3 | * 4 | * Defines the {@link CueEmitter} class. 5 | * 6 | * @module peaks/cues/cue-emitter 7 | */ 8 | 9 | define([ 10 | 'peaks/cues/cue' 11 | ], function(Cue) { 12 | 'use strict'; 13 | 14 | var isHeadless = /HeadlessChrome/.test(navigator.userAgent); 15 | 16 | function windowIsVisible() { 17 | if (isHeadless || navigator.webdriver) { 18 | return false; 19 | } 20 | 21 | return (typeof document === 'object') && 22 | ('visibilityState' in document) && 23 | (document.visibilityState === 'visible'); 24 | } 25 | 26 | var requestAnimationFrame = 27 | window.requestAnimationFrame || 28 | window.mozRequestAnimationFrame || 29 | window.webkitRequestAnimationFrame || 30 | window.msRequestAnimationFrame; 31 | 32 | var cancelAnimationFrame = 33 | window.cancelAnimationFrame || 34 | window.mozCancelAnimationFrame || 35 | window.webkitCancelAnimationFrame || 36 | window.msCancelAnimationFrame; 37 | 38 | var eventTypes = { 39 | forward: {}, 40 | reverse: {} 41 | }; 42 | 43 | var EVENT_TYPE_POINT = 0; 44 | var EVENT_TYPE_SEGMENT_ENTER = 1; 45 | var EVENT_TYPE_SEGMENT_EXIT = 2; 46 | 47 | eventTypes.forward[Cue.POINT] = EVENT_TYPE_POINT; 48 | eventTypes.forward[Cue.SEGMENT_START] = EVENT_TYPE_SEGMENT_ENTER; 49 | eventTypes.forward[Cue.SEGMENT_END] = EVENT_TYPE_SEGMENT_EXIT; 50 | 51 | eventTypes.reverse[Cue.POINT] = EVENT_TYPE_POINT; 52 | eventTypes.reverse[Cue.SEGMENT_START] = EVENT_TYPE_SEGMENT_EXIT; 53 | eventTypes.reverse[Cue.SEGMENT_END] = EVENT_TYPE_SEGMENT_ENTER; 54 | 55 | var eventNames = {}; 56 | 57 | eventNames[EVENT_TYPE_POINT] = 'points.enter'; 58 | eventNames[EVENT_TYPE_SEGMENT_ENTER] = 'segments.enter'; 59 | eventNames[EVENT_TYPE_SEGMENT_EXIT] = 'segments.exit'; 60 | 61 | /** 62 | * Given a cue instance, returns the corresponding {@link Point} 63 | * {@link Segment}. 64 | * 65 | * @param {Peaks} peaks 66 | * @param {Cue} cue 67 | * @return {Point|Segment} 68 | * @throws {Error} 69 | */ 70 | 71 | function getPointOrSegment(peaks, cue) { 72 | switch (cue.type) { 73 | case Cue.POINT: 74 | return peaks.points.getPoint(cue.id); 75 | 76 | case Cue.SEGMENT_START: 77 | case Cue.SEGMENT_END: 78 | return peaks.segments.getSegment(cue.id); 79 | 80 | default: 81 | throw new Error('getPointOrSegment: id not found?'); 82 | } 83 | } 84 | 85 | /** 86 | * CueEmitter is responsible for emitting points.enter, 87 | * segments.enter, and segments.exit events. 88 | * 89 | * @class 90 | * @alias CueEmitter 91 | * 92 | * @param {Peaks} peaks Parent {@link Peaks} instance. 93 | */ 94 | 95 | function CueEmitter(peaks) { 96 | this._cues = []; 97 | this._peaks = peaks; 98 | this._previousTime = -1; 99 | this._updateCues = this._updateCues.bind(this); 100 | // Event handlers: 101 | this._onPlay = this.onPlay.bind(this); 102 | this._onSeek = this.onSeek.bind(this); 103 | this._onTimeUpdate = this.onTimeUpdate.bind(this); 104 | this._onAnimationFrame = this.onAnimationFrame.bind(this); 105 | this._rAFHandle = null; 106 | this._activeSegments = {}; 107 | this._attachEventHandlers(); 108 | } 109 | 110 | /** 111 | * This function is bound to all {@link Peaks} events relating to mutated 112 | * [Points]{@link Point} or [Segments]{@link Segment}, and updates the 113 | * list of cues accordingly. 114 | * 115 | * @private 116 | */ 117 | 118 | CueEmitter.prototype._updateCues = function() { 119 | var self = this; 120 | 121 | var points = self._peaks.points.getPoints(); 122 | var segments = self._peaks.segments.getSegments(); 123 | 124 | self._cues.length = 0; 125 | 126 | points.forEach(function(point) { 127 | self._cues.push(new Cue(point.time, Cue.POINT, point.id)); 128 | }); 129 | 130 | segments.forEach(function(segment) { 131 | self._cues.push(new Cue(segment.startTime, Cue.SEGMENT_START, segment.id)); 132 | self._cues.push(new Cue(segment.endTime, Cue.SEGMENT_END, segment.id)); 133 | }); 134 | 135 | self._cues.sort(Cue.sorter); 136 | 137 | var time = self._peaks.player.getCurrentTime(); 138 | 139 | self._updateActiveSegments(time); 140 | }; 141 | 142 | /** 143 | * Emits events for any cues passed through during media playback. 144 | * 145 | * @param {Number} time The current time on the media timeline. 146 | * @param {Number} previousTime The previous time on the media timeline when 147 | * this function was called. 148 | */ 149 | 150 | CueEmitter.prototype._onUpdate = function(time, previousTime) { 151 | var isForward = time > previousTime; 152 | var start; 153 | var end; 154 | var step; 155 | 156 | if (isForward) { 157 | start = 0; 158 | end = this._cues.length; 159 | step = 1; 160 | } 161 | else { 162 | start = this._cues.length - 1; 163 | end = -1; 164 | step = -1; 165 | } 166 | 167 | // Cues are sorted 168 | for (var i = start; isForward ? i < end : i > end; i += step) { 169 | var cue = this._cues[i]; 170 | 171 | if (isForward ? cue.time > previousTime : cue.time < previousTime) { 172 | if (isForward ? cue.time > time : cue.time < time) { 173 | break; 174 | } 175 | 176 | // Cue falls between time and previousTime 177 | 178 | var marker = getPointOrSegment(this._peaks, cue); 179 | 180 | var eventType = isForward ? eventTypes.forward[cue.type] : 181 | eventTypes.reverse[cue.type]; 182 | 183 | if (eventType === EVENT_TYPE_SEGMENT_ENTER) { 184 | this._activeSegments[marker.id] = marker; 185 | } 186 | else if (eventType === EVENT_TYPE_SEGMENT_EXIT) { 187 | delete this._activeSegments[marker.id]; 188 | } 189 | 190 | this._peaks.emit(eventNames[eventType], marker); 191 | } 192 | } 193 | }; 194 | 195 | // the next handler and onAnimationFrame are bound together 196 | // when the window isn't in focus, rAF is throttled 197 | // falling back to timeUpdate 198 | 199 | CueEmitter.prototype.onTimeUpdate = function(time) { 200 | if (windowIsVisible()) { 201 | return; 202 | } 203 | 204 | if (this._peaks.player.isPlaying() && !this._peaks.player.isSeeking()) { 205 | this._onUpdate(time, this._previousTime); 206 | } 207 | 208 | this._previousTime = time; 209 | }; 210 | 211 | CueEmitter.prototype.onAnimationFrame = function() { 212 | var time = this._peaks.player.getCurrentTime(); 213 | 214 | if (!this._peaks.player.isSeeking()) { 215 | this._onUpdate(time, this._previousTime); 216 | } 217 | 218 | this._previousTime = time; 219 | 220 | if (this._peaks.player.isPlaying()) { 221 | this._rAFHandle = requestAnimationFrame(this._onAnimationFrame); 222 | } 223 | }; 224 | 225 | CueEmitter.prototype.onPlay = function() { 226 | this._previousTime = this._peaks.player.getCurrentTime(); 227 | this._rAFHandle = requestAnimationFrame(this._onAnimationFrame); 228 | }; 229 | 230 | CueEmitter.prototype.onSeek = function(time) { 231 | this._previousTime = time; 232 | 233 | this._updateActiveSegments(time); 234 | }; 235 | 236 | function getSegmentIdComparator(id) { 237 | return function compareSegmentIds(segment) { 238 | return segment.id === id; 239 | }; 240 | } 241 | 242 | /** 243 | * The active segments is the set of all segments which overlap the current 244 | * playhead position. This function updates that set and emits 245 | * segments.enter and segments.exit events. 246 | */ 247 | 248 | CueEmitter.prototype._updateActiveSegments = function(time) { 249 | var self = this; 250 | 251 | var activeSegments = self._peaks.segments.getSegmentsAtTime(time); 252 | 253 | // Remove any segments no longer active. 254 | 255 | for (var id in self._activeSegments) { 256 | if (Object.prototype.hasOwnProperty.call(self._activeSegments, id)) { 257 | var segment = activeSegments.find(getSegmentIdComparator(id)); 258 | 259 | if (!segment) { 260 | self._peaks.emit('segments.exit', self._activeSegments[id]); 261 | delete self._activeSegments[id]; 262 | } 263 | } 264 | } 265 | 266 | // Add new active segments. 267 | 268 | activeSegments.forEach(function(segment) { 269 | if (!(segment.id in self._activeSegments)) { 270 | self._activeSegments[segment.id] = segment; 271 | self._peaks.emit('segments.enter', segment); 272 | } 273 | }); 274 | }; 275 | 276 | var triggerUpdateOn = Array( 277 | 'points.update', 278 | 'points.dragmove', 279 | 'points.add', 280 | 'points.remove', 281 | 'points.remove_all', 282 | 'segments.update', 283 | 'segments.dragged', 284 | 'segments.add', 285 | 'segments.remove', 286 | 'segments.remove_all' 287 | ); 288 | 289 | CueEmitter.prototype._attachEventHandlers = function() { 290 | this._peaks.on('player_time_update', this._onTimeUpdate); 291 | this._peaks.on('player_play', this._onPlay); 292 | this._peaks.on('player_seek', this._onSeek); 293 | 294 | for (var i = 0; i < triggerUpdateOn.length; i++) { 295 | this._peaks.on(triggerUpdateOn[i], this._updateCues); 296 | } 297 | 298 | this._updateCues(); 299 | }; 300 | 301 | CueEmitter.prototype._detachEventHandlers = function() { 302 | this._peaks.off('player_time_update', this._onTimeUpdate); 303 | this._peaks.off('player_play', this._onPlay); 304 | this._peaks.off('player_seek', this._onSeek); 305 | 306 | for (var i = 0; i < triggerUpdateOn.length; i++) { 307 | this._peaks.off(triggerUpdateOn[i], this._updateCues); 308 | } 309 | }; 310 | 311 | CueEmitter.prototype.destroy = function() { 312 | if (this._rAFHandle) { 313 | cancelAnimationFrame(this._rAFHandle); 314 | this._rAFHandle = null; 315 | } 316 | 317 | this._detachEventHandlers(); 318 | 319 | this._previousTime = -1; 320 | this._marks.length = 0; 321 | }; 322 | 323 | return CueEmitter; 324 | }); 325 | -------------------------------------------------------------------------------- /src/main/waveform/waveform.mixins.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 3 | * 4 | * Common functions used in multiple modules are collected here for DRY purposes. 5 | * 6 | * @module peaks/waveform/waveform.mixins 7 | */ 8 | 9 | define(['konva'], function(Konva) { 10 | 'use strict'; 11 | 12 | /** 13 | * Parameters for the {@link createSegmentMarker} function. 14 | * 15 | * @typedef {Object} CreateSegmentMarkerOptions 16 | * @global 17 | * @property {Boolean} draggable If true, marker is draggable. 18 | * @property {Number} height Height of handle group container (canvas). 19 | * @property {String} color Colour hex value for handle and line marker. 20 | * @property {Boolean} inMarker Is this marker the inMarker (LHS) or outMarker (RHS). 21 | * @property {Konva.Group} segmentGroup 22 | * @property {Object} segment 23 | * @property {Konva.Layer} layer 24 | * @property {Function} onDrag Callback after drag completed. 25 | */ 26 | 27 | /** 28 | * Creates a Left or Right side segment handle group in Konva based on the 29 | * given options. 30 | * 31 | * @param {CreateSegmentMarkerOptions} options 32 | * @returns {Konva.Group} Konva group object of handle marker element. 33 | */ 34 | 35 | function createSegmentMarker(options) { 36 | var handleHeight = 20; 37 | var handleWidth = handleHeight / 2; 38 | var handleY = (options.height / 2) - 10.5; 39 | var handleX = -(handleWidth / 2) + 0.5; 40 | 41 | var group = new Konva.Group({ 42 | draggable: options.draggable, 43 | dragBoundFunc: function(pos) { 44 | var limit; 45 | 46 | if (options.inMarker) { 47 | limit = options.segmentGroup.outMarker.getX() - options.segmentGroup.outMarker.getWidth(); 48 | 49 | if (pos.x > limit) { 50 | pos.x = limit; 51 | } 52 | } 53 | else { 54 | limit = options.segmentGroup.inMarker.getX() + options.segmentGroup.inMarker.getWidth(); 55 | 56 | if (pos.x < limit) { 57 | pos.x = limit; 58 | } 59 | } 60 | 61 | return { 62 | x: pos.x, 63 | y: this.getAbsolutePosition().y 64 | }; 65 | } 66 | }); 67 | 68 | var xPosition = options.inMarker ? -24 : 24; 69 | 70 | var text = new Konva.Text({ 71 | x: xPosition, 72 | y: (options.height / 2) - 5, 73 | text: '', 74 | fontSize: 10, 75 | fontFamily: 'sans-serif', 76 | fill: '#000', 77 | textAlign: 'center' 78 | }); 79 | 80 | text.hide(); 81 | group.label = text; 82 | 83 | var handle = new Konva.Rect({ 84 | x: handleX, 85 | y: handleY, 86 | width: handleWidth, 87 | height: handleHeight, 88 | fill: options.color, 89 | stroke: options.color, 90 | strokeWidth: 1 91 | }); 92 | 93 | // Vertical Line 94 | 95 | var line = new Konva.Line({ 96 | x: 0, 97 | y: 0, 98 | points: [0.5, 0, 0.5, options.height], 99 | stroke: options.color, 100 | strokeWidth: 1 101 | }); 102 | 103 | // Events 104 | 105 | if (options.draggable && options.onDrag) { 106 | group.on('dragmove', function(event) { 107 | options.onDrag(options.segmentGroup, options.segment); 108 | }); 109 | group.on('dragstart', function(event) { 110 | if (options.inMarker) { 111 | text.setX(xPosition - text.getWidth()); 112 | } 113 | text.show(); 114 | options.layer.draw(); 115 | }); 116 | group.on('dragend', function(event) { 117 | text.hide(); 118 | options.layer.draw(); 119 | }); 120 | } 121 | 122 | handle.on('mouseover touchstart', function(event) { 123 | if (options.inMarker) { 124 | text.setX(xPosition - text.getWidth()); 125 | } 126 | text.show(); 127 | options.layer.draw(); 128 | }); 129 | 130 | handle.on('mouseout touchend', function(event) { 131 | text.hide(); 132 | options.layer.draw(); 133 | }); 134 | 135 | group.add(text); 136 | group.add(line); 137 | group.add(handle); 138 | 139 | return group; 140 | } 141 | 142 | /** 143 | * Creates a Konva.Text object that renders a segment's label text. 144 | * 145 | * @param {Konva.Group} segmentGroup 146 | * @param {Segment} segment 147 | * @returns {Konva.Text} 148 | */ 149 | 150 | function createSegmentLabel(segmentGroup, segment) { 151 | return new Konva.Text({ 152 | x: 12, 153 | y: 12, 154 | text: segment.labelText, 155 | textAlign: 'center', 156 | fontSize: 12, 157 | fontFamily: 'Arial, sans-serif', 158 | fill: '#000' 159 | }); 160 | } 161 | 162 | /** 163 | * Parameters for the {@link createPointMarker} function. 164 | * 165 | * @typedef {Object} CreatePointMarkerOptions 166 | * @global 167 | * @property {Boolean} draggable If true, marker is draggable. 168 | * @property {Boolean} showLabel If true, show the label text next to the marker. 169 | * @property {String} handleColor Color hex value for handle and line marker. 170 | * @property {Number} height Height of handle group container (canvas). 171 | * @property {Konva.Group} pointGroup Point marker UI object. 172 | * @property {Object} point Point object with timestamp. 173 | * @property {Konva.Layer} layer Layer that contains the pointGroup. 174 | * @property {Function} onDblClick 175 | * @property {Function} onDragStart 176 | * @property {Function} onDragMove Callback during mouse drag operations. 177 | * @property {Function} onDragEnd 178 | * @property {Function} onMouseOver 179 | * @property {Function} onMouseLeave 180 | */ 181 | 182 | /** 183 | * Creates a point handle group in Konva based on the given options. 184 | * 185 | * @param {CreatePointMarkerOptions} options 186 | * @returns {Konva.Group} Konva group object of handle marker elements 187 | */ 188 | 189 | function createPointMarker(options) { 190 | var handleTop = (options.height / 2) - 10.5; 191 | var handleWidth = 10; 192 | var handleHeight = 20; 193 | var handleX = -(handleWidth / 2) + 0.5; // Place in the middle of the marker 194 | 195 | var group = new Konva.Group({ 196 | draggable: options.draggable, 197 | dragBoundFunc: function(pos) { 198 | return { 199 | x: pos.x, // No constraint horizontally 200 | y: this.getAbsolutePosition().y // Constrained vertical line 201 | }; 202 | } 203 | }); 204 | 205 | if (options.onDragStart) { 206 | group.on('dragstart', function(event) { 207 | options.onDragStart(options.point); 208 | }); 209 | } 210 | 211 | if (options.onDragMove) { 212 | group.on('dragmove', function(event) { 213 | options.onDragMove(options.point); 214 | }); 215 | } 216 | 217 | if (options.onDragEnd) { 218 | group.on('dragend', function(event) { 219 | options.onDragEnd(options.point); 220 | }); 221 | } 222 | 223 | if (options.onDblClick) { 224 | group.on('dblclick', function(event) { 225 | options.onDblClick(options.point); 226 | }); 227 | } 228 | 229 | if (options.onMouseEnter) { 230 | group.on('mouseenter', function(event) { 231 | options.onMouseEnter(options.point); 232 | }); 233 | } 234 | 235 | if (options.onMouseLeave) { 236 | group.on('mouseleave', function(event) { 237 | options.onMouseLeave(options.point); 238 | }); 239 | } 240 | 241 | // Label 242 | var text = null; 243 | 244 | if (options.showLabel) { 245 | text = new Konva.Text({ 246 | x: 2, 247 | y: 12, 248 | text: options.point.labelText, 249 | textAlign: 'left', 250 | fontSize: 10, 251 | fontFamily: 'sans-serif', 252 | fill: '#000' 253 | }); 254 | 255 | group.label = text; 256 | } 257 | 258 | // Handle 259 | var handle = null; 260 | 261 | if (options.draggable) { 262 | handle = new Konva.Rect({ 263 | x: handleX, 264 | y: handleTop, 265 | width: handleWidth, 266 | height: handleHeight, 267 | fill: options.handleColor 268 | }); 269 | } 270 | 271 | // Line 272 | var line = new Konva.Line({ 273 | x: 0, 274 | y: 0, 275 | points: [0, 0, 0, options.height], 276 | stroke: options.handleColor, 277 | strokeWidth: 1 278 | }); 279 | 280 | // Events 281 | var time = null; 282 | 283 | if (handle) { 284 | // Time 285 | time = new Konva.Text({ 286 | x: -24, 287 | y: (options.height / 2) - 5, 288 | text: '', 289 | fontSize: 10, 290 | fontFamily: 'sans-serif', 291 | fill: '#000', 292 | textAlign: 'center' 293 | }); 294 | 295 | time.hide(); 296 | group.time = time; 297 | 298 | handle.on('mouseover touchstart', function(event) { 299 | // Position text to the left of the marker 300 | time.setX(-24 - time.getWidth()); 301 | time.show(); 302 | options.layer.draw(); 303 | }); 304 | 305 | handle.on('mouseout touchend', function(event) { 306 | time.hide(); 307 | options.layer.draw(); 308 | }); 309 | 310 | group.on('dragstart', function(event) { 311 | time.setX(-24 - time.getWidth()); 312 | time.show(); 313 | options.layer.draw(); 314 | }); 315 | 316 | group.on('dragend', function(event) { 317 | time.hide(); 318 | options.layer.draw(); 319 | }); 320 | } 321 | 322 | if (handle) { 323 | group.add(handle); 324 | } 325 | 326 | group.add(line); 327 | 328 | if (text) { 329 | group.add(text); 330 | } 331 | 332 | if (time) { 333 | group.add(time); 334 | } 335 | 336 | return group; 337 | } 338 | 339 | // Public API 340 | 341 | return { 342 | createSegmentMarker: createSegmentMarker, 343 | createPointMarker: createPointMarker, 344 | createSegmentLabel: createSegmentLabel 345 | }; 346 | }); 347 | -------------------------------------------------------------------------------- /src/main/views/waveform.overview.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 3 | * 4 | * Defines the {@link WaveformOverview} class. 5 | * 6 | * @module peaks/views/waveform.overview 7 | */ 8 | 9 | define([ 10 | 'peaks/views/playhead-layer', 11 | 'peaks/views/points-layer', 12 | 'peaks/views/segments-layer', 13 | 'peaks/views/waveform-shape', 14 | 'peaks/views/helpers/mousedraghandler', 15 | 'peaks/waveform/waveform.axis', 16 | 'peaks/waveform/waveform.utils', 17 | 'konva' 18 | ], function( 19 | PlayheadLayer, 20 | PointsLayer, 21 | SegmentsLayer, 22 | WaveformShape, 23 | MouseDragHandler, 24 | WaveformAxis, 25 | Utils, 26 | Konva) { 27 | 'use strict'; 28 | 29 | /** 30 | * Creates the overview waveform view. 31 | * 32 | * @class 33 | * @alias WaveformOverview 34 | * 35 | * @param {WaveformData} waveformData 36 | * @param {HTMLElement} container 37 | * @param {Peaks} peaks 38 | */ 39 | 40 | function WaveformOverview(waveformData, container, peaks) { 41 | var self = this; 42 | 43 | self._originalWaveformData = waveformData; 44 | self._container = container; 45 | self._peaks = peaks; 46 | 47 | self._options = peaks.options; 48 | 49 | self._width = container.clientWidth; 50 | self._height = container.clientHeight || self._options.height; 51 | 52 | if (self._width !== 0) { 53 | self._data = waveformData.resample(self._width); 54 | } 55 | else { 56 | self._data = waveformData; 57 | } 58 | 59 | self._resizeTimeoutId = null; 60 | 61 | self._stage = new Konva.Stage({ 62 | container: container, 63 | width: self._width, 64 | height: self._height 65 | }); 66 | 67 | self._waveformLayer = new Konva.FastLayer(); 68 | 69 | self._axis = new WaveformAxis(self, self._waveformLayer, peaks.options); 70 | 71 | self._createWaveform(); 72 | 73 | self._segmentsLayer = new SegmentsLayer(peaks, self, false); 74 | self._segmentsLayer.addToStage(self._stage); 75 | 76 | self._pointsLayer = new PointsLayer(peaks, self, false, false); 77 | self._pointsLayer.addToStage(self._stage); 78 | 79 | self._createHighlightLayer(); 80 | 81 | self._playheadLayer = new PlayheadLayer( 82 | peaks, 83 | self, 84 | false, // showPlayheadTime 85 | self._options.mediaElement.currentTime 86 | ); 87 | 88 | self._playheadLayer.addToStage(self._stage); 89 | 90 | self._mouseDragHandler = new MouseDragHandler(self._stage, { 91 | onMouseDown: function(mousePosX) { 92 | mousePosX = Utils.clamp(mousePosX, 0, self._width); 93 | 94 | var time = self.pixelsToTime(mousePosX); 95 | 96 | self._playheadLayer.updatePlayheadTime(time); 97 | 98 | peaks.player.seek(time); 99 | }, 100 | 101 | onMouseMove: function(mousePosX) { 102 | mousePosX = Utils.clamp(mousePosX, 0, self._width); 103 | 104 | var time = self.pixelsToTime(mousePosX); 105 | 106 | // Update the playhead position. This gives a smoother visual update 107 | // than if we only use the player_time_update event. 108 | self._playheadLayer.updatePlayheadTime(time); 109 | 110 | self._peaks.player.seek(time); 111 | } 112 | }); 113 | 114 | // Events 115 | 116 | peaks.on('player_play', function(time) { 117 | self._playheadLayer.updatePlayheadTime(time); 118 | }); 119 | 120 | peaks.on('player_pause', function(time) { 121 | self._playheadLayer.stop(time); 122 | }); 123 | 124 | peaks.on('player_time_update', function(time) { 125 | self._playheadLayer.updatePlayheadTime(time); 126 | }); 127 | 128 | peaks.on('zoomview.displaying', function(startTime, endTime) { 129 | if (!self._highlightRect) { 130 | self._createHighlightRect(startTime, endTime); 131 | } 132 | 133 | self._updateHighlightRect(startTime, endTime); 134 | }); 135 | 136 | peaks.on('window_resize', function() { 137 | if (self._resizeTimeoutId) { 138 | clearTimeout(self._resizeTimeoutId); 139 | self._resizeTimeoutId = null; 140 | } 141 | 142 | // Avoid resampling waveform data to zero width 143 | if (self._container.clientWidth !== 0) { 144 | self._width = self._container.clientWidth; 145 | self._stage.setWidth(self._width); 146 | 147 | self._resizeTimeoutId = setTimeout(function() { 148 | self._width = self._container.clientWidth; 149 | self._data = self._originalWaveformData.resample(self._width); 150 | self._stage.setWidth(self._width); 151 | 152 | self._updateWaveform(); 153 | }, 500); 154 | } 155 | }); 156 | } 157 | 158 | WaveformOverview.prototype.setWaveformData = function(waveformData) { 159 | this._originalWaveformData = waveformData; 160 | 161 | if (this._width !== 0) { 162 | this._data = waveformData.resample(this._width); 163 | } 164 | else { 165 | this._data = waveformData; 166 | } 167 | 168 | this._updateWaveform(); 169 | }; 170 | 171 | /** 172 | * Returns the pixel index for a given time, for the current zoom level. 173 | * 174 | * @param {Number} time Time, in seconds. 175 | * @returns {Number} Pixel index. 176 | */ 177 | 178 | WaveformOverview.prototype.timeToPixels = function(time) { 179 | return Math.floor(time * this._data.sample_rate / this._data.scale); 180 | }; 181 | 182 | /** 183 | * Returns the time for a given pixel index, for the current zoom level. 184 | * 185 | * @param {Number} pixels Pixel index. 186 | * @returns {Number} Time, in seconds. 187 | */ 188 | 189 | WaveformOverview.prototype.pixelsToTime = function(pixels) { 190 | return pixels * this._data.scale / this._data.sample_rate; 191 | }; 192 | 193 | /** 194 | * @returns {Number} The start position of the waveform shown in the view, 195 | * in pixels. 196 | */ 197 | 198 | WaveformOverview.prototype.getFrameOffset = function() { 199 | return 0; 200 | }; 201 | 202 | /** 203 | * @returns {Number} The width of the view, in pixels. 204 | */ 205 | 206 | WaveformOverview.prototype.getWidth = function() { 207 | return this._width; 208 | }; 209 | 210 | /** 211 | * @returns {Number} The height of the view, in pixels. 212 | */ 213 | 214 | WaveformOverview.prototype.getHeight = function() { 215 | return this._height; 216 | }; 217 | 218 | /** 219 | * Adjusts the amplitude scale of waveform shown in the view, which allows 220 | * users to zoom the waveform vertically. 221 | * 222 | * @param {Number} scale The new amplitude scale factor 223 | */ 224 | 225 | WaveformOverview.prototype.setAmplitudeScale = function(scale) { 226 | if (!Utils.isNumber(scale) || !Number.isFinite(scale)) { 227 | throw new Error('view.setAmplitudeScale(): Scale must be a valid number'); 228 | } 229 | 230 | this._waveformShape.setAmplitudeScale(scale); 231 | this._waveformLayer.draw(); 232 | }; 233 | 234 | /** 235 | * @returns {WaveformData} The view's waveform data. 236 | */ 237 | 238 | WaveformOverview.prototype.getWaveformData = function() { 239 | return this._data; 240 | }; 241 | 242 | /** 243 | * Creates a {WaveformShape} object that draws the waveform in the view, 244 | * and adds it to the wav 245 | */ 246 | 247 | WaveformOverview.prototype._createWaveform = function() { 248 | this._waveformShape = new WaveformShape({ 249 | color: this._options.overviewWaveformColor, 250 | view: this 251 | }); 252 | 253 | this._waveformLayer.add(this._waveformShape); 254 | this._stage.add(this._waveformLayer); 255 | }; 256 | 257 | WaveformOverview.prototype._createHighlightLayer = function() { 258 | this._highlightLayer = new Konva.FastLayer(); 259 | this._stage.add(this._highlightLayer); 260 | }; 261 | 262 | WaveformOverview.prototype._createHighlightRect = function(startTime, endTime) { 263 | this._highlightRectStartTime = startTime; 264 | this._highlightRectEndTime = endTime; 265 | 266 | var startOffset = this.timeToPixels(startTime); 267 | var endOffset = this.timeToPixels(endTime); 268 | 269 | this._highlightRect = new Konva.Rect({ 270 | startOffset: 0, 271 | y: 11, 272 | width: endOffset - startOffset, 273 | stroke: this._options.overviewHighlightRectangleColor, 274 | strokeWidth: 1, 275 | height: this._height - (11 * 2), 276 | fill: this._options.overviewHighlightRectangleColor, 277 | opacity: 0.3, 278 | cornerRadius: 2 279 | }); 280 | 281 | this._highlightLayer.add(this._highlightRect); 282 | }; 283 | 284 | /** 285 | * Updates the position of the highlight region. 286 | * 287 | * @param {Number} startTime The start of the highlight region, in seconds. 288 | * @param {Number} endTime The end of the highlight region, in seconds. 289 | */ 290 | 291 | WaveformOverview.prototype._updateHighlightRect = function(startTime, endTime) { 292 | this._highlightRectStartTime = startTime; 293 | this._highlightRectEndTime = endTime; 294 | 295 | var startOffset = this.timeToPixels(startTime); 296 | var endOffset = this.timeToPixels(endTime); 297 | 298 | this._highlightRect.setAttrs({ 299 | x: startOffset, 300 | width: endOffset - startOffset 301 | }); 302 | 303 | this._highlightLayer.draw(); 304 | }; 305 | 306 | WaveformOverview.prototype._updateWaveform = function() { 307 | this._waveformLayer.draw(); 308 | 309 | var playheadTime = this._peaks.player.getCurrentTime(); 310 | 311 | this._playheadLayer.updatePlayheadTime(playheadTime); 312 | 313 | if (this._highlightRect) { 314 | this._updateHighlightRect( 315 | this._highlightRectStartTime, 316 | this._highlightRectEndTime 317 | ); 318 | } 319 | 320 | var frameStartTime = 0; 321 | var frameEndTime = this.pixelsToTime(this._width); 322 | 323 | this._pointsLayer.updatePoints(frameStartTime, frameEndTime); 324 | this._segmentsLayer.updateSegments(frameStartTime, frameEndTime); 325 | }; 326 | 327 | WaveformOverview.prototype.setWaveformColor = function(color) { 328 | this._waveformShape.setWaveformColor(color); 329 | this._waveformLayer.draw(); 330 | }; 331 | 332 | WaveformOverview.prototype.showPlayheadTime = function(show) { 333 | this._playheadLayer.showPlayheadTime(show); 334 | }; 335 | 336 | WaveformOverview.prototype.enableAutoScroll = function() { 337 | // The overview waveform doesn't support scrolling, 338 | // so nothing to do here. 339 | }; 340 | 341 | WaveformOverview.prototype.destroy = function() { 342 | if (this._resizeTimeoutId) { 343 | clearTimeout(this._resizeTimeoutId); 344 | this._resizeTimeoutId = null; 345 | } 346 | 347 | if (this._stage) { 348 | this._stage.destroy(); 349 | this._stage = null; 350 | } 351 | }; 352 | 353 | return WaveformOverview; 354 | }); 355 | -------------------------------------------------------------------------------- /src/main/markers/waveform.segments.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 3 | * 4 | * Defines the {@link WaveformSegments} class. 5 | * 6 | * @module peaks/markers/waveform.segments 7 | */ 8 | 9 | define([ 10 | 'colors.css', 11 | 'peaks/markers/segment', 12 | 'peaks/waveform/waveform.utils' 13 | ], function(Colors, Segment, Utils) { 14 | 'use strict'; 15 | 16 | /** 17 | * Segment parameters. 18 | * 19 | * @typedef {Object} SegmentOptions 20 | * @global 21 | * @property {Number} startTime Segment start time, in seconds. 22 | * @property {Number} endTime Segment end time, in seconds. 23 | * @property {Boolean=} editable If true the segment start and 24 | * end times can be adjusted via the user interface. 25 | * Default: false. 26 | * @property {String=} color Segment waveform color. 27 | * Default: a random color. 28 | * @property {String=} labelText Segment label text. 29 | * Default: an empty string. 30 | * @property {String=} id A unique segment identifier. 31 | * Default: an automatically generated identifier. 32 | */ 33 | 34 | /** 35 | * Handles all functionality related to the adding, removing and manipulation 36 | * of segments. 37 | * 38 | * @class 39 | * @alias WaveformSegments 40 | * 41 | * @param {Peaks} peaks The parent Peaks object. 42 | */ 43 | 44 | function WaveformSegments(peaks) { 45 | this._peaks = peaks; 46 | this._segments = []; 47 | this._segmentsById = {}; 48 | this._segmentIdCounter = 0; 49 | this._colorIndex = 0; 50 | } 51 | 52 | /** 53 | * Returns a new unique segment id value. 54 | * 55 | * @private 56 | * @returns {String} 57 | */ 58 | 59 | WaveformSegments.prototype._getNextSegmentId = function() { 60 | return 'peaks.segment.' + this._segmentIdCounter++; 61 | }; 62 | 63 | var colors = [ 64 | Colors.navy, 65 | Colors.blue, 66 | Colors.aqua, 67 | Colors.teal, 68 | // Colors.olive, 69 | // Colors.lime, 70 | // Colors.green, 71 | Colors.yellow, 72 | Colors.orange, 73 | Colors.red, 74 | Colors.maroon, 75 | Colors.fuchsia, 76 | Colors.purple 77 | // Colors.black, 78 | // Colors.gray, 79 | // Colors.silver 80 | ]; 81 | 82 | /** 83 | * @private 84 | * @returns {String} 85 | */ 86 | 87 | WaveformSegments.prototype._getSegmentColor = function() { 88 | if (this._peaks.options.randomizeSegmentColor) { 89 | if (++this._colorIndex === colors.length) { 90 | this._colorIndex = 0; 91 | } 92 | 93 | return colors[this._colorIndex]; 94 | } 95 | else { 96 | return this._peaks.options.segmentColor; 97 | } 98 | }; 99 | 100 | /** 101 | * Adds a new segment object. 102 | * 103 | * @private 104 | * @param {Segment} segment 105 | */ 106 | 107 | WaveformSegments.prototype._addSegment = function(segment) { 108 | this._segments.push(segment); 109 | 110 | this._segmentsById[segment.id] = segment; 111 | }; 112 | 113 | /** 114 | * Creates a new segment object. 115 | * 116 | * @private 117 | * @param {SegmentOptions} options 118 | * @return {Segment} 119 | */ 120 | 121 | WaveformSegments.prototype._createSegment = function(options) { 122 | // Watch for anyone still trying to use the old 123 | // createSegment(startTime, endTime, ...) API 124 | if (!Utils.isObject(options)) { 125 | // eslint-disable-next-line max-len 126 | throw new TypeError('peaks.segments.add(): expected a Segment object parameter'); 127 | } 128 | 129 | var segment = new Segment( 130 | this, 131 | Utils.isNullOrUndefined(options.id) ? this._getNextSegmentId() : options.id, 132 | options.startTime, 133 | options.endTime, 134 | options.labelText || '', 135 | options.color || this._getSegmentColor(), 136 | options.editable || false 137 | ); 138 | 139 | return segment; 140 | }; 141 | 142 | /** 143 | * Returns all segments. 144 | * 145 | * @returns {Array} 146 | */ 147 | 148 | WaveformSegments.prototype.getSegments = function() { 149 | return this._segments; 150 | }; 151 | 152 | /** 153 | * Returns the segment with the given id, or null if not found. 154 | * 155 | * @param {String} id 156 | * @returns {Segment|null} 157 | */ 158 | 159 | WaveformSegments.prototype.getSegment = function(id) { 160 | return this._segmentsById[id] || null; 161 | }; 162 | 163 | /** 164 | * Returns all segments that overlap a given point in time. 165 | * 166 | * @param {Number} time 167 | * @returns {Array} 168 | */ 169 | 170 | WaveformSegments.prototype.getSegmentsAtTime = function(time) { 171 | return this._segments.filter(function(segment) { 172 | return time >= segment.startTime && time < segment.endTime; 173 | }); 174 | }; 175 | 176 | /** 177 | * Returns all segments that overlap a given time region. 178 | * 179 | * @param {Number} startTime The start of the time region, in seconds. 180 | * @param {Number} endTime The end of the time region, in seconds. 181 | * 182 | * @returns {Array} 183 | */ 184 | 185 | WaveformSegments.prototype.find = function(startTime, endTime) { 186 | return this._segments.filter(function(segment) { 187 | return segment.isVisible(startTime, endTime); 188 | }); 189 | }; 190 | 191 | /** 192 | * Adds one or more segments to the timeline. 193 | * 194 | * @param {SegmentOptions|Array} segmentOrSegments 195 | */ 196 | 197 | WaveformSegments.prototype.add = function(segmentOrSegments) { 198 | var self = this; 199 | 200 | var segments = Array.isArray(arguments[0]) ? 201 | arguments[0] : 202 | Array.prototype.slice.call(arguments); 203 | 204 | if (typeof segments[0] === 'number') { 205 | // eslint-disable-next-line max-len 206 | this._peaks.options.deprecationLogger('peaks.segments.add(): expected a segment object or an array'); 207 | 208 | segments = [{ 209 | startTime: arguments[0], 210 | endTime: arguments[1], 211 | editable: arguments[2], 212 | color: arguments[3], 213 | labelText: arguments[4] 214 | }]; 215 | } 216 | 217 | segments = segments.map(function(segmentOptions) { 218 | var segment = self._createSegment(segmentOptions); 219 | 220 | if (Object.prototype.hasOwnProperty.call(self._segmentsById, segment.id)) { 221 | throw new Error('peaks.segments.add(): duplicate id'); 222 | } 223 | 224 | return segment; 225 | }); 226 | 227 | segments.forEach(function(segment) { 228 | self._addSegment(segment); 229 | }); 230 | 231 | this._peaks.emit('segments.add', segments); 232 | }; 233 | 234 | /** 235 | * Returns the indexes of segments that match the given predicate. 236 | * 237 | * @private 238 | * @param {Function} predicate Predicate function to find matching segments. 239 | * @returns {Array} An array of indexes into the segments array of 240 | * the matching elements. 241 | */ 242 | 243 | WaveformSegments.prototype._findSegment = function(predicate) { 244 | var indexes = []; 245 | 246 | for (var i = 0, length = this._segments.length; i < length; i++) { 247 | if (predicate(this._segments[i])) { 248 | indexes.push(i); 249 | } 250 | } 251 | 252 | return indexes; 253 | }; 254 | 255 | /** 256 | * Removes the segments at the given array indexes. 257 | * 258 | * @private 259 | * @param {Array} indexes The array indexes to remove. 260 | * @returns {Array} The removed {@link Segment} objects. 261 | */ 262 | 263 | WaveformSegments.prototype._removeIndexes = function(indexes) { 264 | var removed = []; 265 | 266 | for (var i = 0; i < indexes.length; i++) { 267 | var index = indexes[i] - removed.length; 268 | 269 | var itemRemoved = this._segments.splice(index, 1)[0]; 270 | 271 | delete this._segmentsById[itemRemoved.id]; 272 | 273 | removed.push(itemRemoved); 274 | } 275 | 276 | return removed; 277 | }; 278 | 279 | /** 280 | * Removes all segments that match a given predicate function. 281 | * 282 | * After removing the segments, this function also emits a 283 | * segments.remove event with the removed {@link Segment} 284 | * objects. 285 | * 286 | * @private 287 | * @param {Function} predicate A predicate function that identifies which 288 | * segments to remove. 289 | * @returns {Array} The removed {@link Segment} objects. 290 | */ 291 | 292 | WaveformSegments.prototype._removeSegments = function(predicate) { 293 | var indexes = this._findSegment(predicate); 294 | 295 | var removed = this._removeIndexes(indexes); 296 | 297 | this._peaks.emit('segments.remove', removed); 298 | 299 | return removed; 300 | }; 301 | 302 | /** 303 | * Removes the given segment. 304 | * 305 | * @param {Segment} segment The segment to remove. 306 | * @returns {Array} The removed segment. 307 | */ 308 | 309 | WaveformSegments.prototype.remove = function(segment) { 310 | return this._removeSegments(function(s) { 311 | return s === segment; 312 | }); 313 | }; 314 | 315 | /** 316 | * Removes any segments with the given id. 317 | * 318 | * @param {String} id 319 | * @returns {Array} The removed {@link Segment} objects. 320 | */ 321 | 322 | WaveformSegments.prototype.removeById = function(segmentId) { 323 | return this._removeSegments(function(segment) { 324 | return segment.id === segmentId; 325 | }); 326 | }; 327 | 328 | /** 329 | * Removes any segments with the given start time, and optional end time. 330 | * 331 | * @param {Number} startTime Segments with this start time are removed. 332 | * @param {Number?} endTime If present, only segments with both the given 333 | * start time and end time are removed. 334 | * @returns {Array} The removed {@link Segment} objects. 335 | */ 336 | 337 | WaveformSegments.prototype.removeByTime = function(startTime, endTime) { 338 | endTime = (typeof endTime === 'number') ? endTime : 0; 339 | 340 | var fnFilter; 341 | 342 | if (endTime > 0) { 343 | fnFilter = function(segment) { 344 | return segment.startTime === startTime && segment.endTime === endTime; 345 | }; 346 | } 347 | else { 348 | fnFilter = function(segment) { 349 | return segment.startTime === startTime; 350 | }; 351 | } 352 | 353 | return this._removeSegments(fnFilter); 354 | }; 355 | 356 | /** 357 | * Removes all segments. 358 | * 359 | * After removing the segments, this function emits a 360 | * segments.remove_all event. 361 | */ 362 | 363 | WaveformSegments.prototype.removeAll = function() { 364 | this._segments = []; 365 | this._segmentsById = {}; 366 | this._peaks.emit('segments.remove_all'); 367 | }; 368 | 369 | return WaveformSegments; 370 | }); 371 | --------------------------------------------------------------------------------