From 13c8e5f146b7f8740fda6955dd0fa7c8cc9d6ff8 Mon Sep 17 00:00:00 2001 From: Naomi Date: Tue, 16 Apr 2013 14:37:08 -0400 Subject: [PATCH] CC-2301 : showing waveform cue/fade editors inside of a jquery dialog box. --- .../controllers/LibraryController.php | 18 + .../application/layouts/scripts/layout.phtml | 26 + .../views/scripts/playlist/set-cue.phtml | 1 + .../views/scripts/playlist/set-fade.phtml | 1 + .../views/scripts/playlist/update.phtml | 12 + airtime_mvc/build/schema.xml | 1 + airtime_mvc/public/css/playlist_builder.css | 21 +- airtime_mvc/public/js/airtime/library/spl.js | 95 +++ .../public/js/waveformplaylist/config.js | 162 +++++ .../public/js/waveformplaylist/controls.js | 520 +++++++++++++ .../public/js/waveformplaylist/curves.js | 72 ++ .../public/js/waveformplaylist/fades.js | 140 ++++ .../public/js/waveformplaylist/loader.js | 86 +++ .../js/waveformplaylist/local_storage.js | 16 + .../js/waveformplaylist/observer/observer.js | 57 ++ .../js/waveformplaylist/observer/observer.js~ | 57 ++ .../public/js/waveformplaylist/playlist.js | 328 +++++++++ .../public/js/waveformplaylist/playout.js | 161 ++++ .../waveformplaylist/templates/bottombar.tpl | 25 + .../waveformplaylist/templates/bottombar.tpl~ | 25 + .../public/js/waveformplaylist/time_scale.js | 151 ++++ .../public/js/waveformplaylist/track.js | 686 ++++++++++++++++++ .../js/waveformplaylist/track_render.js | 430 +++++++++++ 23 files changed, 3090 insertions(+), 1 deletion(-) create mode 100644 airtime_mvc/public/js/waveformplaylist/config.js create mode 100644 airtime_mvc/public/js/waveformplaylist/controls.js create mode 100644 airtime_mvc/public/js/waveformplaylist/curves.js create mode 100644 airtime_mvc/public/js/waveformplaylist/fades.js create mode 100644 airtime_mvc/public/js/waveformplaylist/loader.js create mode 100644 airtime_mvc/public/js/waveformplaylist/local_storage.js create mode 100644 airtime_mvc/public/js/waveformplaylist/observer/observer.js create mode 100644 airtime_mvc/public/js/waveformplaylist/observer/observer.js~ create mode 100644 airtime_mvc/public/js/waveformplaylist/playlist.js create mode 100644 airtime_mvc/public/js/waveformplaylist/playout.js create mode 100644 airtime_mvc/public/js/waveformplaylist/templates/bottombar.tpl create mode 100644 airtime_mvc/public/js/waveformplaylist/templates/bottombar.tpl~ create mode 100644 airtime_mvc/public/js/waveformplaylist/time_scale.js create mode 100644 airtime_mvc/public/js/waveformplaylist/track.js create mode 100644 airtime_mvc/public/js/waveformplaylist/track_render.js diff --git a/airtime_mvc/application/controllers/LibraryController.php b/airtime_mvc/application/controllers/LibraryController.php index 79dd4b2be..0f3a155b2 100644 --- a/airtime_mvc/application/controllers/LibraryController.php +++ b/airtime_mvc/application/controllers/LibraryController.php @@ -52,6 +52,24 @@ class LibraryController extends Zend_Controller_Action $this->view->headScript()->appendFile($baseUrl.'js/airtime/library/spl.js?'.$CC_CONFIG['airtime_version'], 'text/javascript'); $this->view->headScript()->appendFile($baseUrl.'js/airtime/playlist/smart_blockbuilder.js?'.$CC_CONFIG['airtime_version'], 'text/javascript'); + + $this->view->headScript()->appendFile($baseUrl.'js/waveformplaylist/observer/observer.js?'.$CC_CONFIG['airtime_version'], 'text/javascript'); + $this->view->headScript()->appendFile($baseUrl.'js/waveformplaylist/config.js?'.$CC_CONFIG['airtime_version'], 'text/javascript'); + $this->view->headScript()->appendFile($baseUrl.'js/waveformplaylist/curves.js?'.$CC_CONFIG['airtime_version'], 'text/javascript'); + $this->view->headScript()->appendFile($baseUrl.'js/waveformplaylist/fades.js?'.$CC_CONFIG['airtime_version'], 'text/javascript'); + $this->view->headScript()->appendFile($baseUrl.'js/waveformplaylist/local_storage.js?'.$CC_CONFIG['airtime_version'], 'text/javascript'); + $this->view->headScript()->appendFile($baseUrl.'js/waveformplaylist/controls.js?'.$CC_CONFIG['airtime_version'], 'text/javascript'); + $this->view->headScript()->appendFile($baseUrl.'js/waveformplaylist/playout.js?'.$CC_CONFIG['airtime_version'], 'text/javascript'); + $this->view->headScript()->appendFile($baseUrl.'js/waveformplaylist/track_render.js?'.$CC_CONFIG['airtime_version'], 'text/javascript'); + $this->view->headScript()->appendFile($baseUrl.'js/waveformplaylist/track.js?'.$CC_CONFIG['airtime_version'], 'text/javascript'); + $this->view->headScript()->appendFile($baseUrl.'js/waveformplaylist/time_scale.js?'.$CC_CONFIG['airtime_version'], 'text/javascript'); + $this->view->headScript()->appendFile($baseUrl.'js/waveformplaylist/playlist.js?'.$CC_CONFIG['airtime_version'], 'text/javascript'); + + //arbitrary attributes need to be allowed to set an id for the templates. + $this->view->headScript()->setAllowArbitraryAttributes(true); + //$this->view->headScript()->appendScript(file_get_contents(APPLICATION_PATH.'/../public/js/waveformplaylist/templates/bottombar.tpl'), + // 'text/template', array('id' => 'tpl_playlist_cues', 'noescape' => true)); + $this->view->headLink()->appendStylesheet($baseUrl.'css/playlist_builder.css?'.$CC_CONFIG['airtime_version']); try { diff --git a/airtime_mvc/application/layouts/scripts/layout.phtml b/airtime_mvc/application/layouts/scripts/layout.phtml index dedda7c88..8a37a403f 100644 --- a/airtime_mvc/application/layouts/scripts/layout.phtml +++ b/airtime_mvc/application/layouts/scripts/layout.phtml @@ -33,5 +33,31 @@
layout()->content ?>
+ + + + diff --git a/airtime_mvc/application/views/scripts/playlist/set-cue.phtml b/airtime_mvc/application/views/scripts/playlist/set-cue.phtml index 176d8b8ac..5a01d8c50 100644 --- a/airtime_mvc/application/views/scripts/playlist/set-cue.phtml +++ b/airtime_mvc/application/views/scripts/playlist/set-cue.phtml @@ -1,3 +1,4 @@ +
diff --git a/airtime_mvc/application/views/scripts/playlist/set-fade.phtml b/airtime_mvc/application/views/scripts/playlist/set-fade.phtml index 17f1e7c60..8f57d3cce 100644 --- a/airtime_mvc/application/views/scripts/playlist/set-fade.phtml +++ b/airtime_mvc/application/views/scripts/playlist/set-fade.phtml @@ -1,3 +1,4 @@ +
item1Type == 0) {?>
diff --git a/airtime_mvc/application/views/scripts/playlist/update.phtml b/airtime_mvc/application/views/scripts/playlist/update.phtml index 20ef17665..d1d85324c 100644 --- a/airtime_mvc/application/views/scripts/playlist/update.phtml +++ b/airtime_mvc/application/views/scripts/playlist/update.phtml @@ -8,6 +8,15 @@ if ($item['type'] == 2) { $bl= new Application_Model_Block($item['item_id']); $staticBlock = $bl->isStatic(); } +else if ($item['type'] == 0) { + $audiofile = Application_Model_StoredFile::Recall($item['item_id']); + $fileUrl = $audiofile->getFileUrl(); +} + +if (($i < count($items) -1) && ($items[$i+1]['type'] == 0)) { + $nextAudiofile = Application_Model_StoredFile::Recall($items[$i+1]['item_id']); + $nextFileUrl = $nextAudiofile->getFileUrl(); +} ?>
  • " unqid="">
    @@ -65,6 +74,7 @@ if ($item['type'] == 2) { 'id' => $item["id"], 'cueIn' => $item['cuein'], 'cueOut' => $item['cueout'], + 'uri' => $fileUrl, 'origLength' => $item['orig_length'])); ?>
    @@ -80,6 +90,8 @@ if ($item['type'] == 2) { 'item2' => $items[$i+1]['id'], 'item1Type' => $items[$i]['type'], 'item2Type' => $items[$i+1]['type'], + 'item1Url' => $fileUrl, + 'item2Url' => $nextFileUrl, 'fadeOut' => $items[$i]['fadeout'], 'fadeIn' => $items[$i+1]['fadein'])); ?> diff --git a/airtime_mvc/build/schema.xml b/airtime_mvc/build/schema.xml index b9a8a6951..6ecde0276 100644 --- a/airtime_mvc/build/schema.xml +++ b/airtime_mvc/build/schema.xml @@ -227,6 +227,7 @@ --> + diff --git a/airtime_mvc/public/css/playlist_builder.css b/airtime_mvc/public/css/playlist_builder.css index 43b942a7f..19c06b848 100644 --- a/airtime_mvc/public/css/playlist_builder.css +++ b/airtime_mvc/public/css/playlist_builder.css @@ -28,7 +28,7 @@ height: 28px; margin: 0 7px 20px 0; }*/ -#side_playlist input,#side_playlist textarea { +#side_playlist textarea { width: 200px; } @@ -585,3 +585,22 @@ li.spl_empty { .expand-block-separate { border-top: 1px solid #5B5B5B; } + +.channel-wrapper { + position: relative; +} + +.channel { + position: absolute; + margin: 0; + padding: 0; +} + +.state-select { + cursor: text; +} + +.playlist-tracks { + overflow-x: auto; + overflow-y: hidden; +} \ No newline at end of file diff --git a/airtime_mvc/public/js/airtime/library/spl.js b/airtime_mvc/public/js/airtime/library/spl.js index 93d1cd80d..f4ceaff24 100644 --- a/airtime_mvc/public/js/airtime/library/spl.js +++ b/airtime_mvc/public/js/airtime/library/spl.js @@ -1061,6 +1061,93 @@ var AIRTIME = (function(AIRTIME){ playlistRequest(sUrl, oData); }; + mod.showFadesWaveform = function(e) { + var $el = $(e.target), + $parent = $el.parent(), + trackEditor = new TrackEditor(), + audioControls = new AudioControls(), + trackElem, + config, + $html = $($("#tmpl-pl-fades").html()), + tracks = [ + { + src: $parent.data("fadeout") + }, + { + src: $parent.data("fadein") + } + ]; + + //$el.replaceWith(html); + + $html.dialog({ + modal: true, + title: "Fade Editor", + show: 'clip', + hide: 'clip', + width: 900, + height: 300, + buttons: [ + //{text: "Submit", click: function() {doSomething()}}, + {text: "Cancel", click: function() {$(this).dialog("close");}} + ] + }); + + config = new Config({ + resolution: 15000, + state: "shift", + mono: true, + waveHeight: 80, + container: $html[0], + UITheme: "jQueryUI" + }); + + var playlistEditor = new PlaylistEditor(); + playlistEditor.setConfig(config); + playlistEditor.init(tracks); + }; + + mod.showCuesWaveform = function(e) { + var $el = $(e.target), + $parent = $el.parent(), + uri = $parent.data("uri"), + trackEditor = new TrackEditor(), + audioControls = new AudioControls(), + trackElem, + config, + $html = $($("#tmpl-pl-cues").html()), + tracks = [{ + src: uri + }]; + + //$el.replaceWith(html); + + $html.dialog({ + modal: true, + title: "Cue Editor", + show: 'clip', + hide: 'clip', + width: 900, + height: 300, + buttons: [ + //{text: "Submit", click: function() {doSomething()}}, + {text: "Cancel", click: function() {$(this).dialog("close");}} + ] + }); + + config = new Config({ + resolution: 15000, + mono: true, + waveHeight: 80, + container: $html[0], + UITheme: "jQueryUI" + }); + + var playlistEditor = new PlaylistEditor(); + playlistEditor.setConfig(config); + playlistEditor.init(tracks); + }; + mod.init = function() { /* $.contextMenu({ @@ -1089,6 +1176,14 @@ var AIRTIME = (function(AIRTIME){ AIRTIME.playlist.fnWsDelete(); }}); + $pl.delegate(".pl-waveform-cues-btn", {"click": function(ev){ + AIRTIME.playlist.showCuesWaveform(ev); + }}); + + $pl.delegate(".pl-waveform-fades-btn", {"click": function(ev){ + AIRTIME.playlist.showFadesWaveform(ev); + }}); + setPlaylistEntryEvents(); setCueEvents(); setFadeEvents(); diff --git a/airtime_mvc/public/js/waveformplaylist/config.js b/airtime_mvc/public/js/waveformplaylist/config.js new file mode 100644 index 000000000..0aec0b339 --- /dev/null +++ b/airtime_mvc/public/js/waveformplaylist/config.js @@ -0,0 +1,162 @@ +/* + Stores configuration settings for the playlist builder. + A container object (ex a div) must be passed in, the playlist will be built on this element. +*/ + +var Config = function(params) { + + var that = this, + defaultParams; + + defaultParams = { + + ac: new (window.AudioContext || window.webkitAudioContext), + + resolution: 4096, //resolution - samples per pixel to draw. + timeFormat: 'hh:mm:ss.uuu', + mono: true, //whether to draw multiple channels or combine them. + + timescale: false, //whether or not to include the time measure. + + UITheme: "default", // bootstrap || jQueryUI || default + + waveColor: 'grey', + progressColor: 'orange', + loadingColor: 'purple', + cursorColor: 'green', + markerColor: 'green', + selectBorderColor: 'red', + selectBackgroundColor: 'rgba(0,0,0,0.1)', + + timeColor: 'grey', + fontColor: 'black', + fadeColor: 'black', + + waveHeight: 128, //height of each canvas element a waveform is on. + + trackscroll: { + left: 0, + top: 0 + }, + + state: 'select', + + cursorPos: 0 //value is kept in seconds. + }; + + params = Object.create(params); + Object.keys(defaultParams).forEach(function(key) { + if (!(key in params)) { + params[key] = defaultParams[key]; + } + }); + + + /* + Start of all getter methods for config. + */ + + that.getContainer = function getContainer() { + return params.container; + }; + + that.isTimeScaleEnabled = function isTimeScaleEnabled() { + return params.timescale; + }; + + that.isDisplayMono = function isDisplayMono() { + return params.mono; + }; + + that.getUITheme = function getUITheme() { + return params.UITheme; + }; + + that.getCursorPos = function getCursorPos() { + return params.cursorPos; + }; + + that.getState = function getState() { + return params.state; + }; + + that.getAudioContext = function getAudioContext() { + return params.ac; + }; + + that.getSampleRate = function getSampleRate() { + return params.ac.sampleRate; + }; + + that.getCurrentTime = function getCurrentTime() { + return params.ac.currentTime; + }; + + that.getTimeFormat = function getTimeFormat() { + return params.timeFormat; + }; + + that.getResolution = function getResolution() { + return params.resolution; + }; + + that.getWaveHeight = function getWaveHeight() { + return params.waveHeight; + }; + + that.getColorScheme = function getColorScheme() { + return { + waveColor: params.waveColor, + progressColor: params.progressColor, + loadingColor: params.loadingColor, + cursorColor: params.cursorColor, + markerColor: params.markerColor, + timeColor: params.timeColor, + fontColor: params.fontColor, + fadeColor: params.fadeColor, + selectBorderColor: params.selectBorderColor, + selectBackgroundColor: params.selectBackgroundColor, + }; + }; + + that.getTrackScroll = function getTrackScroll() { + var scroll = params.trackscroll; + + return { + left: scroll.left, + top: scroll.top + }; + }; + + + /* + Start of all setter methods for config. + */ + + that.setResolution = function setResolution(resolution) { + params.resolution = resolution; + }; + + that.setTimeFormat = function setTimeFormat(format) { + params.timeFormat = format; + }; + + that.setDisplayMono = function setDisplayMono(bool) { + params.mono = bool; + }; + + that.setCursorPos = function setCursorPos(pos) { + params.cursorPos = pos; + }; + + that.setState = function setState(state) { + params.state = state; + }; + + that.setTrackScroll = function setTrackScroll(left, top) { + var scroll = params.trackscroll; + + scroll.left = left; + scroll.top = top; + }; +}; diff --git a/airtime_mvc/public/js/waveformplaylist/controls.js b/airtime_mvc/public/js/waveformplaylist/controls.js new file mode 100644 index 000000000..0a19a0968 --- /dev/null +++ b/airtime_mvc/public/js/waveformplaylist/controls.js @@ -0,0 +1,520 @@ +'use strict'; + +var AudioControls = function() { + +}; + +AudioControls.prototype.groups = { + "audio-select": ["btns_audio_tools", "btns_fade"] +}; + +AudioControls.prototype.classes = { + "btn-state-active": "btn btn-mini active", + "btn-state-default": "btn btn-mini", + "disabled": "disabled", + "active": "active" +}; + +AudioControls.prototype.events = { + "btn_rewind": { + click: "rewindAudio" + }, + + "btn_play": { + click: "playAudio" + }, + + "btn_stop": { + click: "stopAudio" + }, + + "btn_select": { + click: "changeState" + }, + + "btn_shift": { + click: "changeState" + }, + + "btns_fade": { + click: "createFade" + }, + + "btn_save": { + click: "save" + }, + + "btn_open": { + click: "open" + }, + + "btn_trim_audio": { + click: "trimAudio" + }, + + "time_format": { + change: "changeTimeFormat" + }, + + "audio_start": { + blur: "validateCueIn" + }, + + "audio_end": { + blur: "validateCueOut" + }, + + "audio_pos": { + + }, + + "audio_resolution": { + change: "changeResolution" + } +}; + +AudioControls.prototype.validateCue = function(value) { + var validators, + regex, + result; + + validators = { + "seconds": /^\d+$/, + + "thousandths": /^\d+\.\d{3}$/, + + "hh:mm:ss": /^[0-9]{2,}:[0-5][0-9]:[0-5][0-9]$/, + + "hh:mm:ss.uu": /^[0-9]{2,}:[0-5][0-9]:[0-5][0-9]\.\d{2}$/, + + "hh:mm:ss.uuu": /^[0-9]{2,}:[0-5][0-9]:[0-5][0-9]\.\d{3}$/ + }; + + regex = validators[this.timeFormat]; + result = regex.test(value); + + return result; +}; + +AudioControls.prototype.cueToSeconds = function(value) { + var converter, + func, + seconds; + + function clockConverter(value) { + var data = value.split(":"), + hours = parseInt(data[0], 10) * 3600, + mins = parseInt(data[1], 10) * 60, + secs = parseFloat(data[2]), + seconds; + + seconds = hours + mins + secs; + + return seconds; + } + + converter = { + "seconds": function(value) { + return parseInt(value, 10); + }, + + "thousandths": function(value) { + return parseFloat(value); + }, + + "hh:mm:ss": function(value) { + return clockConverter(value); + }, + + "hh:mm:ss.uu": function(value) { + return clockConverter(value); + }, + + "hh:mm:ss.uuu": function(value) { + return clockConverter(value); + } + }; + + func = converter[this.timeFormat]; + seconds = func(value); + + return seconds; +}; + +AudioControls.prototype.cueFormatters = function(format) { + + function clockFormat(seconds, decimals) { + var hours, + minutes, + secs, + result; + + hours = parseInt(seconds / 3600, 10) % 24; + minutes = parseInt(seconds / 60, 10) % 60; + secs = seconds % 60; + secs = secs.toFixed(decimals); + + result = (hours < 10 ? "0" + hours : hours) + ":" + (minutes < 10 ? "0" + minutes : minutes) + ":" + (secs < 10 ? "0" + secs : secs); + + return result; + } + + var formats = { + "seconds": function (seconds) { + return seconds.toFixed(0); + }, + + "thousandths": function (seconds) { + return seconds.toFixed(3); + }, + + "hh:mm:ss": function (seconds) { + return clockFormat(seconds, 0); + }, + + "hh:mm:ss.uu": function (seconds) { + return clockFormat(seconds, 2); + }, + + "hh:mm:ss.uuu": function (seconds) { + return clockFormat(seconds, 3); + } + }; + + return formats[format]; +}; + +AudioControls.prototype.init = function(config) { + var that = this, + className, + event, + events = this.events, + tmpEl, + func, + state, + container, + tmpBtn; + + makePublisher(this); + + this.ctrls = {}; + this.config = config; + container = this.config.getContainer(); + state = this.config.getState(); + + tmpBtn = document.getElementById("btn_"+state); + + if (tmpBtn) { + tmpBtn.className = this.classes["btn-state-active"]; + } + + for (className in events) { + + tmpEl = container.getElementsByClassName(className)[0]; + this.ctrls[className] = tmpEl; + + for (event in events[className]) { + + if (tmpEl) { + func = that[events[className][event]].bind(that); + tmpEl.addEventListener(event, func); + } + } + } + + if (this.ctrls["time_format"]) { + this.ctrls["time_format"].value = this.config.getTimeFormat(); + } + + if (this.ctrls["audio_resolution"]) { + this.ctrls["audio_resolution"].value = this.config.getResolution(); + } + + this.timeFormat = this.config.getTimeFormat(); + + //Kept in seconds so time format change can update fields easily. + this.currentSelectionValues = undefined; + + this.onCursorSelection({ + start: 0, + end: 0 + }); +}; + +AudioControls.prototype.changeTimeFormat = function(e) { + var format = e.target.value, + func, start, end; + + format = (this.cueFormatters(format) !== undefined) ? format : "hh:mm:ss"; + this.config.setTimeFormat(format); + this.timeFormat = format; + + if (this.currentSelectionValues !== undefined) { + func = this.cueFormatters(format); + start = this.currentSelectionValues.start; + end = this.currentSelectionValues.end; + + if (this.ctrls["audio_start"]) { + this.ctrls["audio_start"].value = func(start); + } + + if (this.ctrls["audio_end"]) { + this.ctrls["audio_end"].value = func(end); + } + } +}; + +AudioControls.prototype.changeResolution = function(e) { + var res = parseInt(e.target.value, 10); + + this.config.setResolution(res); + this.fire("changeresolution", res); +}; + +AudioControls.prototype.validateCueIn = function(e) { + var value = e.target.value, + end, + startSecs; + + if (this.validateCue(value)) { + end = this.currentSelectionValues.end; + startSecs = this.cueToSeconds(value); + + if (startSecs <= end) { + this.notifySelectionUpdate(startSecs, end); + this.currentSelectionValues.start = startSecs; + return; + } + } + + //time entered was otherwise invalid. + e.target.value = this.cueFormatters(this.timeFormat)(this.currentSelectionValues.start); +}; + +AudioControls.prototype.validateCueOut = function(e) { + var value = e.target.value, + start, + endSecs; + + if (this.validateCue(value)) { + start = this.currentSelectionValues.start; + endSecs = this.cueToSeconds(value); + + if (endSecs >= start) { + this.notifySelectionUpdate(start, endSecs); + this.currentSelectionValues.end = endSecs; + return; + } + } + + //time entered was otherwise invalid. + e.target.value = this.cueFormatters(this.timeFormat)(this.currentSelectionValues.end); +}; + +AudioControls.prototype.activateButtonGroup = function(id) { + var el = document.getElementById(id), + btns, + classes = this.classes, + i, len; + + if (el === null) { + return; + } + + btns = el.getElementsByTagName("a"); + + for (i = 0, len = btns.length; i < len; i++) { + btns[i].classList.remove(classes["disabled"]); + } +}; + +AudioControls.prototype.deactivateButtonGroup = function(id) { + var el = document.getElementById(id), + btns, + classes = this.classes, + i, len; + + if (el === null) { + return; + } + + btns = el.getElementsByTagName("a"); + + for (i = 0, len = btns.length; i < len; i++) { + btns[i].classList.add(classes["disabled"]); + } +}; + +AudioControls.prototype.activateAudioSelection = function() { + var ids = this.groups["audio-select"], + i, len; + + for (i = 0, len = ids.length; i < len; i++) { + this.activateButtonGroup(ids[i]); + } +}; + +AudioControls.prototype.deactivateAudioSelection = function() { + var ids = this.groups["audio-select"], + i, len; + + for (i = 0, len = ids.length; i < len; i++) { + this.deactivateButtonGroup(ids[i]); + } +}; + +AudioControls.prototype.save = function() { + + this.fire('playlistsave', this); +}; + +AudioControls.prototype.open = function() { + + this.fire('playlistrestore', this); +}; + +AudioControls.prototype.rewindAudio = function() { + + this.fire('rewindaudio', this); +}; + +AudioControls.prototype.playAudio = function() { + + this.fire('playaudio', this); +}; + +AudioControls.prototype.stopAudio = function() { + + this.fire('stopaudio', this); +}; + +AudioControls.prototype.setButtonState = function(el, classname) { + el && el.className = this.classes[classname]; +}; + +AudioControls.prototype.changeState = function(e) { + var el = e.currentTarget, + state = el.dataset.state; + + this.el.getElementsByClassName('active')[0].className = classes["btn-state-default"]; + + this.setButtonState("btn-state-default"); + this.setButtonState(el, "btn-state-active"); + + this.config.setState(state); + this.fire('changestate', this); +}; + +AudioControls.prototype.zeroCrossing = function(e) { + var el = e.target, + disabled, + classes = this.classes; + + disabled = el.classList.contains(classes["disabled"]); + + if (!disabled) { + this.fire('trackedit', { + type: "zeroCrossing" + }); + } +}; + +AudioControls.prototype.trimAudio = function(e) { + var el = e.target, + disabled, + classes = this.classes; + + disabled = el.classList.contains(classes["disabled"]); + + if (!disabled) { + this.fire('trackedit', { + type: "trimAudio" + }); + } +}; + +AudioControls.prototype.removeAudio = function(e) { + var el = e.target, + disabled, + classes = this.classes; + + disabled = el.classList.contains(classes["disabled"]); + + if (!disabled) { + this.fire('trackedit', { + type: "removeAudio" + }); + } +}; + +AudioControls.prototype.createFade = function(e) { + var el = e.target, + shape = el.dataset.shape, + type = el.dataset.type, + disabled, + classes = this.classes; + + disabled = el.classList.contains(classes["disabled"]); + + if (!disabled) { + this.fire('trackedit', { + type: "createFade", + args: { + type: type, + shape: shape + } + }); + } +}; + +AudioControls.prototype.onAudioSelection = function() { + this.activateAudioSelection(); +}; + +AudioControls.prototype.onAudioDeselection = function() { + this.deactivateAudioSelection(); +}; + +/* + start, end in seconds +*/ +AudioControls.prototype.notifySelectionUpdate = function(start, end) { + + this.fire('changeselection', { + start: start, + end: end + }); +}; + +/* + start, end in seconds +*/ +AudioControls.prototype.onCursorSelection = function(args) { + var startFormat = this.cueFormatters(this.timeFormat)(args.start), + endFormat = this.cueFormatters(this.timeFormat)(args.end), + start = this.cueToSeconds(startFormat), + end = this.cueToSeconds(endFormat); + + this.currentSelectionValues = { + start: start, + end:end + }; + + if (this.ctrls["audio_start"]) { + this.ctrls["audio_start"].value = startFormat; + } + + if (this.ctrls["audio_end"]) { + this.ctrls["audio_end"].value = endFormat; + } +}; + +/* + args {seconds, pixels} +*/ +AudioControls.prototype.onAudioUpdate = function(args) { + if (this.ctrls["audio_pos"]) { + this.ctrls["audio_pos"].value = this.cueFormatters(this.timeFormat)(args.seconds); + } +}; + diff --git a/airtime_mvc/public/js/waveformplaylist/curves.js b/airtime_mvc/public/js/waveformplaylist/curves.js new file mode 100644 index 000000000..17c364050 --- /dev/null +++ b/airtime_mvc/public/js/waveformplaylist/curves.js @@ -0,0 +1,72 @@ +var Curves = {}; + +Curves.createLinearBuffer = function createLinearBuffer(length, rotation) { + var curve = new Float32Array(length), + i, x, + scale = length - 1; + + for (i = 0; i < length; i++) { + x = i / scale; + + if (rotation > 0) { + curve[i] = x; + } + else { + curve[i] = 1 - x; + } + } + return curve; +}; + +Curves.createExponentialBuffer = function createExponentialBuffer(length, rotation) { + var curve = new Float32Array(length), + i, x, + scale = length - 1, + index; + + for (i = 0; i < length; i++) { + x = i / scale; + index = rotation > 0 ? i : length - 1 - i; + + curve[index] = Math.exp(2 * x - 1) / Math.exp(1); + } + return curve; +}; + +//creating a curve to simulate an S-curve with setValueCurveAtTime. +Curves.createSCurveBuffer = function createSCurveBuffer(length, phase) { + var curve = new Float32Array(length), + i; + + for (i = 0; i < length; ++i) { + curve[i] = (Math.sin((Math.PI * i / length) - phase)) /2 + 0.5; + } + return curve; +}; + +//creating a curve to simulate a logarithmic curve with setValueCurveAtTime. +Curves.createLogarithmicBuffer = function createLogarithmicBuffer(length, base, rotation) { + var curve = new Float32Array(length), + index, + key = ""+length+base+rotation, + store = [], + x = 0, + i; + + if (store[key]) { + return store[key]; + } + + for (i = 0; i < length; i++) { + //index for the curve array. + index = rotation > 0 ? i : length - 1 - i; + + x = i / length; + curve[index] = Math.log(1 + base*x) / Math.log(1 + base); + } + + store[key] = curve; + + return curve; +}; + diff --git a/airtime_mvc/public/js/waveformplaylist/fades.js b/airtime_mvc/public/js/waveformplaylist/fades.js new file mode 100644 index 000000000..059bda629 --- /dev/null +++ b/airtime_mvc/public/js/waveformplaylist/fades.js @@ -0,0 +1,140 @@ +var Fades = function() {}; + +Fades.prototype.init = function init(sampleRate) { + + this.sampleRate = sampleRate; +} + +/* +The setValueCurveAtTime method +Sets an array of arbitrary parameter values starting at the given time for the given duration. The number of values will be scaled to fit into the desired duration. + +The values parameter is a Float32Array representing a parameter value curve. These values will apply starting at the given time and lasting for the given duration. + +The startTime parameter is the time in the same time coordinate system as AudioContext.currentTime. + +The duration parameter is the amount of time in seconds (after the time parameter) where values will be calculated according to the values parameter.. + +During the time interval: startTime <= t < startTime + duration, values will be calculated: + + v(t) = values[N * (t - startTime) / duration], where N is the length of the values array. + +After the end of the curve time interval (t >= startTime + duration), the value will remain constant at the final curve value, until there is another automation event (if any). +*/ + +Fades.prototype.sCurveFadeIn = function sCurveFadeIn(gain, start, duration, options) { + var curve; + + curve = Curves.createSCurveBuffer(this.sampleRate, (Math.PI/2)); + gain.setValueCurveAtTime(curve, start, duration); +}; + +Fades.prototype.sCurveFadeOut = function sCurveFadeOut(gain, start, duration, options) { + var curve; + + curve = Curves.createSCurveBuffer(this.sampleRate, -(Math.PI/2)); + gain.setValueCurveAtTime(curve, start, duration); +}; + +/* + +The linearRampToValueAtTime method +Schedules a linear continuous change in parameter value from the previous scheduled parameter value to the given value. + +The value parameter is the value the parameter will linearly ramp to at the given time. + +The endTime parameter is the time in the same time coordinate system as AudioContext.currentTime. + +The value during the time interval T0 <= t < T1 (where T0 is the time of the previous event and T1 is the endTime parameter passed into this method) will be calculated as: + + v(t) = V0 + (V1 - V0) * ((t - T0) / (T1 - T0)) + +Where V0 is the value at the time T0 and V1 is the value parameter passed into this method. + +If there are no more events after this LinearRampToValue event then for t >= T1, v(t) = V1 + +*/ +Fades.prototype.linearFadeIn = function linearFadeIn(gain, start, duration, options) { + + gain.linearRampToValueAtTime(0, start); + gain.linearRampToValueAtTime(1, start + duration); +}; + +Fades.prototype.linearFadeOut = function linearFadeOut(gain, start, duration, options) { + + gain.linearRampToValueAtTime(1, start); + gain.linearRampToValueAtTime(0, start + duration); +}; + +/* +DOES NOT WORK PROPERLY USING 0 + +The exponentialRampToValueAtTime method +Schedules an exponential continuous change in parameter value from the previous scheduled parameter value to the given value. Parameters representing filter frequencies and playback rate are best changed exponentially because of the way humans perceive sound. + +The value parameter is the value the parameter will exponentially ramp to at the given time. An exception will be thrown if this value is less than or equal to 0, or if the value at the time of the previous event is less than or equal to 0. + +The endTime parameter is the time in the same time coordinate system as AudioContext.currentTime. + +The value during the time interval T0 <= t < T1 (where T0 is the time of the previous event and T1 is the endTime parameter passed into this method) will be calculated as: + + v(t) = V0 * (V1 / V0) ^ ((t - T0) / (T1 - T0)) + +Where V0 is the value at the time T0 and V1 is the value parameter passed into this method. + +If there are no more events after this ExponentialRampToValue event then for t >= T1, v(t) = V1 +*/ +Fades.prototype.exponentialFadeIn = function exponentialFadeIn(gain, start, duration, options) { + + gain.exponentialRampToValueAtTime(0.01, start); + gain.exponentialRampToValueAtTime(1, start + duration); +}; + +Fades.prototype.exponentialFadeOut = function exponentialFadeOut(gain, start, duration, options) { + + gain.exponentialRampToValueAtTime(1, start); + gain.exponentialRampToValueAtTime(0.01, start + duration); +}; + +Fades.prototype.logarithmicFadeIn = function logarithmicFadeIn(gain, start, duration, options) { + var curve, + base = options.base; + + base = typeof base !== 'undefined' ? base : 10; + + curve = Curves.createLogarithmicBuffer(this.sampleRate, base, 1); + gain.setValueCurveAtTime(curve, start, duration); +}; + +Fades.prototype.logarithmicFadeOut = function logarithmicFadeOut(gain, start, duration, options) { + var curve, + base = options.base; + + base = typeof base !== 'undefined' ? base : 10; + + curve = Curves.createLogarithmicBuffer(this.sampleRate, base, -1); + gain.setValueCurveAtTime(curve, start, duration); +}; + +/** + Calls the appropriate fade type with options + + options { + start, + duration, + base (for logarithmic) + } +*/ +Fades.prototype.createFadeIn = function createFadeIn(gain, type, options) { + var method = type + "FadeIn", + fn = this[method]; + + fn.call(this, gain, options.start, options.duration, options); +}; + +Fades.prototype.createFadeOut = function createFadeOut(gain, type, options) { + var method = type + "FadeOut", + fn = this[method]; + + fn.call(this, gain, options.start, options.duration, options); +}; diff --git a/airtime_mvc/public/js/waveformplaylist/loader.js b/airtime_mvc/public/js/waveformplaylist/loader.js new file mode 100644 index 000000000..03e5ae080 --- /dev/null +++ b/airtime_mvc/public/js/waveformplaylist/loader.js @@ -0,0 +1,86 @@ +'use strict'; + +var BufferLoader = function() { + +} + +BufferLoader.prototype.init = function(params) { + + var loader = this; + loader.context = params.context; + loader.bufferList = []; + loader.loadCount = 0; + + loader.defaultParams = { + + }; + + loader.params = Object.create(params); + Object.keys(loader.defaultParams).forEach(function (key) { + if (!(key in params)) { params[key] = loader.defaultParams[key]; } + }); +} + +BufferLoader.prototype.requestBuffer = function(url, name) { + var loader = this, + request = new XMLHttpRequest(); + + request.open("GET", url, true); + request.responseType = "arraybuffer"; + + request.onload = function() { + loader.context.decodeAudioData(request.response, function(buffer) { + if (!buffer) { + alert('error decoding file data: '+url); + return; + } + + loader.loadCount++; + loader.onAudioFileLoad(name, buffer); + + if (loader.loadCount === loader.urlList.length) { + loader.onAudioFilesDone(loader.bufferList); + } + }, + function(error) { + console.error('decodeAudioData error',error); + }); + } + + request.onerror = function(){ + alert('BufferLoader: XHR error'); + }; + + request.send(); +}; + +BufferLoader.prototype.loadAudio = function(aUrls, callback) { + + var names=[]; + var paths=[]; + + for (var name in aUrls) { + var path = aUrls[name]; + names.push(name); + paths.push(path); + } + + this.urlList = paths; + + var i, + length; + + for (i = 0, length = paths.length; i < length; i++) { + this.requestBuffer(paths[i], names[i]); + } +} + +BufferLoader.prototype.onAudioFileLoad = function(name, buffer) { + + this.bufferList[name] = buffer; +} + +BufferLoader.prototype.onAudioFilesDone = function(bufferList) { + var fn = this.params.onComplete; + fn(bufferList); +} diff --git a/airtime_mvc/public/js/waveformplaylist/local_storage.js b/airtime_mvc/public/js/waveformplaylist/local_storage.js new file mode 100644 index 000000000..f814f173d --- /dev/null +++ b/airtime_mvc/public/js/waveformplaylist/local_storage.js @@ -0,0 +1,16 @@ +var Storage = function() {}; + +Storage.prototype.save = function save(name, playlist) { + var json = JSON.stringify(playlist); + + localStorage.setItem(name, json); +}; + +Storage.prototype.restore = function restore(name) { + var JSONstring = localStorage.getItem(name), + data; + + data = JSON.parse(JSONstring); + + return data; +}; diff --git a/airtime_mvc/public/js/waveformplaylist/observer/observer.js b/airtime_mvc/public/js/waveformplaylist/observer/observer.js new file mode 100644 index 000000000..5be2bb3d9 --- /dev/null +++ b/airtime_mvc/public/js/waveformplaylist/observer/observer.js @@ -0,0 +1,57 @@ +/* +Code taken from http://www.jspatterns.com/book/7/observer-game.html + +Pub/Sub +*/ + +var publisher = { + subscribers: { + any: [] + }, + on: function (type, fn, context) { + type = type || 'any'; + fn = typeof fn === "function" ? fn : context[fn]; + + if (typeof this.subscribers[type] === "undefined") { + this.subscribers[type] = []; + } + this.subscribers[type].push({fn: fn, context: context || this}); + }, + remove: function (type, fn, context) { + this.visitSubscribers('unsubscribe', type, fn, context); + }, + fire: function (type, publication) { + this.visitSubscribers('publish', type, publication); + }, + reset: function (type) { + this.subscribers[type] = undefined; + }, + visitSubscribers: function (action, type, arg, context) { + var pubtype = type || 'any', + subscribers = this.subscribers[pubtype], + i, + max = subscribers ? subscribers.length : 0; + + for (i = 0; i < max; i += 1) { + if (action === 'publish') { + subscribers[i].fn.call(subscribers[i].context, arg); + } + else { + if (subscribers[i].fn === arg && subscribers[i].context === context) { + subscribers.splice(i, 1); + } + } + } + } +}; + + +function makePublisher(o) { + var i; + for (i in publisher) { + if (publisher.hasOwnProperty(i) && typeof publisher[i] === "function") { + o[i] = publisher[i]; + } + } + o.subscribers = {any: []}; +} diff --git a/airtime_mvc/public/js/waveformplaylist/observer/observer.js~ b/airtime_mvc/public/js/waveformplaylist/observer/observer.js~ new file mode 100644 index 000000000..2367a50f9 --- /dev/null +++ b/airtime_mvc/public/js/waveformplaylist/observer/observer.js~ @@ -0,0 +1,57 @@ +/* +Code taken from http://www.jspatterns.com/book/7/observer-game.html + +Pub/Sub +*/ + +var publisher = { + subscribers: { + any: [] + }, + on: function (type, fn, context) { + type = type || 'any'; + fn = typeof fn === "function" ? fn : context[fn]; + + if (typeof this.subscribers[type] === "undefined") { + this.subscribers[type] = []; + } + this.subscribers[type].push({fn: fn, context: context || this}); + }, + remove: function (type, fn, context) { + this.visitSubscribers('unsubscribe', type, fn, context); + }, + fire: function (type, publication) { + this.visitSubscribers('publish', type, publication); + }, + reset: function (type) { + + }, + visitSubscribers: function (action, type, arg, context) { + var pubtype = type || 'any', + subscribers = this.subscribers[pubtype], + i, + max = subscribers ? subscribers.length : 0; + + for (i = 0; i < max; i += 1) { + if (action === 'publish') { + subscribers[i].fn.call(subscribers[i].context, arg); + } + else { + if (subscribers[i].fn === arg && subscribers[i].context === context) { + subscribers.splice(i, 1); + } + } + } + } +}; + + +function makePublisher(o) { + var i; + for (i in publisher) { + if (publisher.hasOwnProperty(i) && typeof publisher[i] === "function") { + o[i] = publisher[i]; + } + } + o.subscribers = {any: []}; +} diff --git a/airtime_mvc/public/js/waveformplaylist/playlist.js b/airtime_mvc/public/js/waveformplaylist/playlist.js new file mode 100644 index 000000000..92887c0c6 --- /dev/null +++ b/airtime_mvc/public/js/waveformplaylist/playlist.js @@ -0,0 +1,328 @@ +'use strict'; + +var PlaylistEditor = function() { + +}; + +PlaylistEditor.prototype.setConfig = function(config) { + this.config = config; +}; + +PlaylistEditor.prototype.init = function(tracks) { + + var that = this, + i, + len, + container = this.config.getContainer(), + div = container.getElementsByClassName("playlist-tracks")[0], + fragment = document.createDocumentFragment(), + trackEditor, + trackElem, + timeScale, + audioControls; + + makePublisher(this); + + this.storage = new Storage(); + + this.trackContainer = div; + this.trackEditors = []; + + audioControls = new AudioControls(); + audioControls.init(this.config); + + if (this.config.isTimeScaleEnabled()) { + timeScale = new TimeScale(); + timeScale.init(this.config); + audioControls.on("changeresolution", "onResolutionChange", timeScale); + this.on("trackscroll", "onTrackScroll", timeScale); + } + + this.timeScale = timeScale; + + for (i = 0, len = tracks.length; i < len; i++) { + + trackEditor = new TrackEditor(); + trackEditor.setConfig(this.config); + trackElem = trackEditor.loadTrack(tracks[i]); + + this.trackEditors.push(trackEditor); + fragment.appendChild(trackElem); + + audioControls.on("changestate", "onStateChange", trackEditor); + audioControls.on("trackedit", "onTrackEdit", trackEditor); + audioControls.on("changeresolution", "onResolutionChange", trackEditor); + + trackEditor.on("activateSelection", "onAudioSelection", audioControls); + trackEditor.on("deactivateSelection", "onAudioDeselection", audioControls); + trackEditor.on("changecursor", "onCursorSelection", audioControls); + trackEditor.on("changecursor", "onSelectUpdate", this); + } + + div.innerHTML = ''; + div.appendChild(fragment); + div.onscroll = this.onTrackScroll.bind(that); + + this.sampleRate = this.config.getSampleRate(); + + this.scrollTimeout = false; + + //for setInterval that's toggled during play/stop. + this.interval; + + this.on("playbackcursor", "onAudioUpdate", audioControls); + + audioControls.on("playlistsave", "save", this); + audioControls.on("playlistrestore", "restore", this); + audioControls.on("rewindaudio", "rewind", this); + audioControls.on("playaudio", "play", this); + audioControls.on("stopaudio", "stop", this); + audioControls.on("trimaudio", "onTrimAudio", this); + audioControls.on("removeaudio", "onRemoveAudio", this); + audioControls.on("changestate", "onStateChange", this); + audioControls.on("changeselection", "onSelectionChange", this); +}; + +PlaylistEditor.prototype.onTrimAudio = function() { + var track = this.activeTrack, + selected = track.getSelectedArea(), + start, end; + + if (selected === undefined) { + return; + } + + track.trim(selected.start, selected.end); +}; + +PlaylistEditor.prototype.onRemoveAudio = function() { + var track = this.activeTrack, + selected = track.getSelectedArea(), + start, end; + + if (selected === undefined) { + return; + } + + track.removeAudio(selected.start, selected.end); +}; + +PlaylistEditor.prototype.onSelectionChange = function(args) { + + if (this.activeTrack === undefined) { + return; + } + + var res = this.config.getResolution(), + start = ~~(args.start * this.sampleRate / res), + end = ~~(args.end * this.sampleRate / res); + + this.config.setCursorPos(args.start); + this.activeTrack.setSelectedArea(start, end); + this.activeTrack.updateEditor(-1, undefined, undefined, true); +}; + +PlaylistEditor.prototype.onStateChange = function() { + var that = this, + editors = this.trackEditors, + i, + len, + editor; + + for(i = 0, len = editors.length; i < len; i++) { + editors[i].deactivate(); + } +}; + +PlaylistEditor.prototype.onTrackScroll = function(e) { + var that = this, + el = e.srcElement; + + if (that.scrollTimeout) return; + + //limit the scroll firing to every 25ms. + that.scrollTimeout = setTimeout(function() { + + that.config.setTrackScroll(el.scrollLeft, el.scrollTop); + that.fire('trackscroll', e); + that.scrollTimeout = false; + }, 25); +}; + +PlaylistEditor.prototype.activateTrack = function(trackEditor) { + var that = this, + editors = this.trackEditors, + i, + len, + editor; + + for (i = 0, len = editors.length; i < len; i++) { + editor = editors[i]; + + if (editor === trackEditor) { + editor.activate(); + this.activeTrack = trackEditor; + } + else { + editor.deactivate(); + } + } +}; + +PlaylistEditor.prototype.onSelectUpdate = function(event) { + + this.activateTrack(event.editor); +}; + +PlaylistEditor.prototype.resetCursor = function() { + this.config.setCursorPos(0); + this.notifySelectUpdate(0, 0); +}; + +PlaylistEditor.prototype.onCursorSelection = function(args) { + this.activateTrack(args.editor); +}; + +PlaylistEditor.prototype.rewind = function() { + + if (this.activeTrack !== undefined) { + this.activeTrack.resetCursor(); + } + else { + this.resetCursor(); + } + + this.stop(); +}; + +/* + returns selected time in global (playlist relative) seconds. +*/ +PlaylistEditor.prototype.getSelected = function() { + var selected, + start, + end; + + if (this.activeTrack) { + selected = this.activeTrack.selectedArea; + if (selected !== undefined && (selected.end > selected.start)) { + return this.activeTrack.getSelectedPlayTime(); + } + } +}; + +PlaylistEditor.prototype.isPlaying = function() { + var that = this, + editors = this.trackEditors, + i, + len, + isPlaying = false; + + for (i = 0, len = editors.length; i < len; i++) { + isPlaying = isPlaying || editors[i].isPlaying(); + } + + return isPlaying; +}; + +PlaylistEditor.prototype.play = function() { + var that = this, + editors = this.trackEditors, + i, + len, + currentTime = this.config.getCurrentTime(), + delay = 0.2, + startTime = this.config.getCursorPos(), + endTime, + selected = this.getSelected(); + + if (selected !== undefined) { + startTime = selected.startTime; + endTime = selected.endTime; + } + + for (i = 0, len = editors.length; i < len; i++) { + editors[i].schedulePlay(currentTime, delay, startTime, endTime); + } + + this.lastPlay = currentTime + delay; + this.interval = setInterval(that.updateEditor.bind(that), 25); +}; + +PlaylistEditor.prototype.stop = function() { + var editors = this.trackEditors, + i, + len, + currentTime = this.config.getCurrentTime(); + + clearInterval(this.interval); + + for (i = 0, len = editors.length; i < len; i++) { + editors[i].scheduleStop(currentTime); + editors[i].updateEditor(-1, undefined, undefined, true); + } +}; + +PlaylistEditor.prototype.updateEditor = function() { + var editors = this.trackEditors, + i, + len, + currentTime = this.config.getCurrentTime(), + elapsed = currentTime - this.lastPlay, + res = this.config.getResolution(), + cursorPos = this.config.getCursorPos(), + cursorPixel, + playbackSec, + selected = this.getSelected(), + start, end, + highlighted = false; + + if (selected !== undefined) { + start = ~~(selected.startTime * this.sampleRate / res); + end = Math.ceil(selected.endTime * this.sampleRate / res); + highlighted = true; + } + + if (this.isPlaying()) { + + if (elapsed) { + playbackSec = cursorPos + elapsed; + cursorPixel = Math.ceil(playbackSec * this.sampleRate / res); + + for(i = 0, len = editors.length; i < len; i++) { + editors[i].updateEditor(cursorPixel, start, end, highlighted); + } + + this.fire("playbackcursor", { + "seconds": playbackSec, + "pixels": cursorPixel + }); + } + } + else { + clearInterval(this.interval); + } +}; + +PlaylistEditor.prototype.save = function() { + var editors = this.trackEditors, + i, + len, + info = []; + + for (i = 0, len = editors.length; i < len; i++) { + info.push(editors[i].getTrackDetails()); + } + + this.storage.save("test", info); +}; + +PlaylistEditor.prototype.restore = function() { + var state; + + state = this.storage.restore("test"); + + this.trackContainer.innerHTML=''; + this.init(state); +}; + diff --git a/airtime_mvc/public/js/waveformplaylist/playout.js b/airtime_mvc/public/js/waveformplaylist/playout.js new file mode 100644 index 000000000..8a30098b2 --- /dev/null +++ b/airtime_mvc/public/js/waveformplaylist/playout.js @@ -0,0 +1,161 @@ +'use strict'; + +var AudioPlayout = function() { + +}; + +AudioPlayout.prototype.init = function(config) { + + makePublisher(this); + + this.config = config; + this.ac = this.config.getAudioContext(); + + this.fadeMaker = new Fades(); + this.fadeMaker.init(this.ac.sampleRate); + + this.gainNode = undefined; + this.destination = this.ac.destination; + this.analyser = this.ac.createAnalyser(); + this.analyser.connect(this.destination); +}; + +AudioPlayout.prototype.getBuffer = function() { + return this.buffer; +}; + +AudioPlayout.prototype.setBuffer = function(buffer) { + this.buffer = buffer; +}; + +/* + param relPos: cursor position in seconds relative to this track. + can be negative if the cursor is placed before the start of this track etc. +*/ +AudioPlayout.prototype.applyFades = function(fades, relPos, now, delay) { + var id, + fade, + fn, + options, + startTime, + duration; + + this.gainNode && this.gainNode.disconnect(); + this.gainNode = this.ac.createGainNode(); + + for (id in fades) { + + fade = fades[id]; + + if (relPos <= fade.start) { + startTime = now + (fade.start - relPos) + delay; + duration = fade.end - fade.start; + } + else if (relPos > fade.start && relPos < fade.end) { + startTime = now - (relPos - fade.start) + delay; + duration = fade.end - fade.start; + } + + options = { + start: startTime, + duration: duration + }; + + if (fades.hasOwnProperty(id)) { + fn = this.fadeMaker["create"+fade.type]; + fn.call(this.fadeMaker, this.gainNode.gain, fade.shape, options); + } + } +}; + +/** + * Loads audiobuffer. + * + * @param {AudioBuffer} audioData Audio data. + */ +AudioPlayout.prototype.loadData = function (audioData, cb) { + var that = this; + + this.ac.decodeAudioData( + audioData, + function (buffer) { + that.buffer = buffer; + cb(buffer); + }, + Error + ); +}; + +AudioPlayout.prototype.isUnScheduled = function() { + return this.source && (this.source.playbackState === this.source.UNSCHEDULED_STATE); +}; + +AudioPlayout.prototype.isScheduled = function() { + return this.source && (this.source.playbackState === this.source.SCHEDULED_STATE); +}; + +AudioPlayout.prototype.isPlaying = function() { + return this.source && (this.source.playbackState === this.source.PLAYING_STATE); +}; + +AudioPlayout.prototype.isFinished = function() { + return this.source && (this.source.playbackState === this.source.FINISHED_STATE); +}; + +AudioPlayout.prototype.getDuration = function() { + return this.buffer.duration; +}; + +AudioPlayout.prototype.getPlayOffset = function() { + var offset = 0; + + //TODO needs a fix for when the buffer naturally plays out. But also have to mind the entire playlist. + if (this.playing) { + offset = this.secondsOffset + (this.ac.currentTime - this.playTime); + } + else { + offset = this.secondsOffset; + } + + return offset; +}; + +AudioPlayout.prototype.setPlayedPercents = function(percent) { + this.secondsOffset = this.getDuration() * percent; +}; + +AudioPlayout.prototype.getPlayedPercents = function() { + return this.getPlayOffset() / this.getDuration(); +}; + +AudioPlayout.prototype.setSource = function(source) { + this.source && this.source.disconnect(); + this.source = source; + this.source.buffer = this.buffer; + + this.source.connect(this.gainNode); + this.gainNode.connect(this.analyser); +}; + +/* + source.start is picky when passing the end time. + If rounding error causes a number to make the source think + it is playing slightly more samples than it has it won't play at all. + Unfortunately it doesn't seem to work if you just give it a start time. +*/ +AudioPlayout.prototype.play = function(when, start, duration) { + if (!this.buffer) { + console.error("no buffer to play"); + return; + } + + this.setSource(this.ac.createBufferSource()); + + this.source.start(when || 0, start, duration); +}; + +AudioPlayout.prototype.stop = function(when) { + + this.source && this.source.stop(when || 0); +}; + diff --git a/airtime_mvc/public/js/waveformplaylist/templates/bottombar.tpl b/airtime_mvc/public/js/waveformplaylist/templates/bottombar.tpl new file mode 100644 index 000000000..bd2d2051c --- /dev/null +++ b/airtime_mvc/public/js/waveformplaylist/templates/bottombar.tpl @@ -0,0 +1,25 @@ +
    + + + + + +
    diff --git a/airtime_mvc/public/js/waveformplaylist/templates/bottombar.tpl~ b/airtime_mvc/public/js/waveformplaylist/templates/bottombar.tpl~ new file mode 100644 index 000000000..bd2d2051c --- /dev/null +++ b/airtime_mvc/public/js/waveformplaylist/templates/bottombar.tpl~ @@ -0,0 +1,25 @@ +
    + + + + + +
    diff --git a/airtime_mvc/public/js/waveformplaylist/time_scale.js b/airtime_mvc/public/js/waveformplaylist/time_scale.js new file mode 100644 index 000000000..e762df42b --- /dev/null +++ b/airtime_mvc/public/js/waveformplaylist/time_scale.js @@ -0,0 +1,151 @@ +'use strict'; + +var TimeScale = function() { + +}; + +TimeScale.prototype.init = function(config) { + + var that = this, + canv, + div; + + makePublisher(this); + + div = document.getElementsByClassName("playlist-time-scale")[0]; + + if (div === undefined) { + return; + } + + canv = document.createElement("canvas"); + this.canv = canv; + this.context = canv.getContext('2d'); + this.config = config; + this.container = div; //container for the main time scale. + + //TODO check for window resizes to set these. + this.width = this.container.clientWidth; + this.height = this.container.clientHeight; + + canv.setAttribute('width', this.width); + canv.setAttribute('height', this.height); + + //array of divs displaying time every 30 seconds. (TODO should make this depend on resolution) + this.times = []; + + this.prevScrollPos = 0; //checking the horizontal scroll (must update timeline above in case of change) + + this.drawScale(); +}; + +/* + Return time in format mm:ss +*/ +TimeScale.prototype.formatTime = function(seconds) { + var out, m, s; + + s = seconds % 60; + m = (seconds - s) / 60; + + if (s < 10) { + s = "0"+s; + } + + out = m + ":" + s; + + return out; +}; + +TimeScale.prototype.clear = function() { + + this.container.innerHTML = ""; + this.context.clearRect(0, 0, this.width, this.height); +}; + +TimeScale.prototype.drawScale = function(offset) { + var cc = this.context, + canv = this.canv, + colors = this.config.getColorScheme(), + pix, + res = this.config.getResolution(), + SR = this.config.getSampleRate(), + pixPerSec = SR/res, + pixOffset = offset || 0, //caused by scrolling horizontally + i, + end, + counter = 0, + pixIndex, + container = this.container, + width = this.width, + height = this.height, + div, + time, + sTime, + fragment = document.createDocumentFragment(), + scaleY, + scaleHeight; + + + this.clear(); + + fragment.appendChild(canv); + cc.fillStyle = colors.timeColor; + end = width + pixOffset; + + for (i = 0; i < end; i = i + pixPerSec) { + + pixIndex = ~~(i); + pix = pixIndex - pixOffset; + + if (pixIndex >= pixOffset) { + + //put a timestamp every 30 seconds. + if (counter % 30 === 0) { + + sTime = this.formatTime(counter); + time = document.createTextNode(sTime); + div = document.createElement("div"); + + div.style.left = pix+"px"; + div.appendChild(time); + fragment.appendChild(div); + + scaleHeight = 10; + scaleY = height - scaleHeight; + } + else if (counter % 5 === 0) { + scaleHeight = 5; + scaleY = height - scaleHeight; + } + else { + scaleHeight = 2; + scaleY = height - scaleHeight; + } + + cc.fillRect(pix, scaleY, 1, scaleHeight); + } + + counter++; + } + + container.appendChild(fragment); +}; + +TimeScale.prototype.onTrackScroll = function() { + var scroll = this.config.getTrackScroll(), + scrollX = scroll.left; + + if (scrollX !== this.prevScrollPos) { + this.prevScrollPos = scrollX; + this.drawScale(scrollX); + } +}; + +TimeScale.prototype.onResolutionChange = function() { + var scroll = this.config.getTrackScroll(), + scrollX = scroll.left; + + this.drawScale(scrollX); +}; + diff --git a/airtime_mvc/public/js/waveformplaylist/track.js b/airtime_mvc/public/js/waveformplaylist/track.js new file mode 100644 index 000000000..b583ae4cb --- /dev/null +++ b/airtime_mvc/public/js/waveformplaylist/track.js @@ -0,0 +1,686 @@ +'use strict'; + +var TrackEditor = function() { + +}; + +TrackEditor.prototype.states = { + select: { + events: { + mousedown: "selectStart" + }, + + classes: [ + "state-select" + ] + }, + + shift: { + events: { + mousedown: "timeShift" + }, + + classes: [ + "state-shift" + ] + } +}; + +TrackEditor.prototype.setConfig = function(config) { + this.config = config; +}; + +TrackEditor.prototype.setWidth = function(width) { + this.width = width; +}; + +TrackEditor.prototype.init = function(src, start, end, fades, cues) { + + makePublisher(this); + + this.container = document.createElement("div"); + + this.drawer = new WaveformDrawer(); + this.drawer.init(this.container, this.config); + + this.playout = new AudioPlayout(); + this.playout.init(this.config); + + this.sampleRate = this.config.getSampleRate(); + this.resolution = this.config.getResolution(); + + //value is a float in seconds + this.startTime = start || 0; + //value is a float in seconds + this.endTime = end || 0; //set properly in onTrackLoad. + + this.leftOffset = this.secondsToSamples(this.startTime); //value is measured in samples. + + this.prevStateEvents = {}; + this.setState(this.config.getState()); + + this.fades = fades || {}; + + if (cues.cuein !== undefined) { + this.setCuePoints(this.secondsToSamples(cues.cuein), this.secondsToSamples(cues.cueout)); + } + + this.selectedArea = undefined; //selected area of track stored as inclusive buffer indices to the audio buffer. + this.active = false; + + this.container.classList.add("channel-wrapper"); + this.container.style.left = this.leftOffset; + + this.drawer.drawLoading(); + + return this.container; +}; + +TrackEditor.prototype.getFadeId = function() { + var id = ""+Math.random(); + + return id.replace(".", ""); +}; + +TrackEditor.prototype.getBuffer = function() { + return this.playout.getBuffer(); +}; + +TrackEditor.prototype.setBuffer = function(buffer) { + this.playout.setBuffer(buffer); +}; + + +TrackEditor.prototype.loadTrack = function(track) { + var el; + + el = this.init( + track.src, + track.start, + track.end, + track.fades, + { + cuein: track.cuein, + cueout: track.cueout + } + ); + this.loadBuffer(track.src); + + return el; +}; + +/** + * Loads an audio file via XHR. + */ +TrackEditor.prototype.loadBuffer = function(src) { + var that = this, + xhr = new XMLHttpRequest(); + + xhr.responseType = 'arraybuffer'; + + xhr.addEventListener('progress', function(e) { + var percentComplete; + + if (e.lengthComputable) { + percentComplete = e.loaded / e.total * 100; + that.drawer.updateLoader(percentComplete); + } + + }, false); + + xhr.addEventListener('load', function(e) { + that.src = src; + that.drawer.setLoaderState("decoding"); + + that.playout.loadData( + e.target.response, + that.onTrackLoad.bind(that) + ); + }, false); + + xhr.open('GET', src, true); + xhr.send(); +}; + +TrackEditor.prototype.drawTrack = function(buffer) { + + this.drawer.drawBuffer(buffer, this.getPixelOffset(this.leftOffset), this.cues); + this.drawer.drawFades(this.fades); +}; + +TrackEditor.prototype.onTrackLoad = function(buffer) { + var res; + + if (this.cues === undefined) { + this.setCuePoints(0, buffer.length - 1); + } + + if (this.width !== undefined) { + res = Math.ceil(buffer.length / this.width); + + this.config.setResolution(res); + this.resolution = res; + } + + this.drawTrack(buffer); +}; + +TrackEditor.prototype.samplesToSeconds = function(samples) { + return samples / this.sampleRate; +}; + +TrackEditor.prototype.secondsToSamples = function(seconds) { + return Math.ceil(seconds * this.sampleRate); +}; + +TrackEditor.prototype.samplesToPixels = function(samples) { + return ~~(samples / this.resolution); +}; + +TrackEditor.prototype.pixelsToSamples = function(pixels) { + return ~~(pixels * this.resolution); +}; + +TrackEditor.prototype.pixelsToSeconds = function(pixels) { + return pixels * this.resolution / this.sampleRate; +}; + +TrackEditor.prototype.secondsToPixels = function(seconds) { + return ~~(seconds * this.sampleRate / this.resolution); +}; + +TrackEditor.prototype.getPixelOffset = function() { + return this.leftOffset / this.resolution; +}; + +TrackEditor.prototype.activate = function() { + this.active = true; + this.container.classList.add("active"); +}; + +TrackEditor.prototype.deactivate = function() { + this.active = false; + this.selectedArea = undefined; + this.container.classList.remove("active"); + this.drawer.draw(-1, this.getPixelOffset()); +}; + +/* start of state methods */ + +TrackEditor.prototype.timeShift = function(e) { + var el = e.currentTarget, //want the events placed on the channel wrapper. + startX = e.pageX, + diffX = 0, + origX = 0, + updatedX = 0, + editor = this, + res = editor.resolution, + scroll = this.config.getTrackScroll(), + scrollX = scroll.left; + + origX = editor.leftOffset/res; + + //dynamically put an event on the element. + el.onmousemove = function(e) { + var endX = e.pageX; + + diffX = endX - startX; + updatedX = origX + diffX; + editor.drawer.setTimeShift(updatedX); + editor.leftOffset = editor.pixelsToSamples(updatedX); + }; + el.onmouseup = function() { + var delta; + + el.onmousemove = document.body.onmouseup = null; + editor.leftOffset = editor.pixelsToSamples(updatedX); + delta = editor.pixelsToSeconds(diffX); + + //update track's start and end time relative to the playlist. + editor.startTime = editor.startTime + delta; + editor.endTime = editor.endTime + delta; + }; +}; + +/* + startTime, endTime in seconds. +*/ +TrackEditor.prototype.notifySelectUpdate = function(startTime, endTime) { + + this.fire('changecursor', { + start: startTime, + end: endTime, + editor: this + }); +}; + + +TrackEditor.prototype.getSelectedPlayTime = function() { + var selected = this.selectedArea, + offset = this.leftOffset, + start = this.samplesToSeconds(offset + selected.start), + end = this.samplesToSeconds(offset + selected.end); + + return { + startTime: start, + endTime: end + } +}; + + +TrackEditor.prototype.getSelectedArea = function() { + return this.selectedArea; +}; + +/* + start, end in samples. +*/ +TrackEditor.prototype.adjustSelectedArea = function(start, end) { + var buffer = this.getBuffer(); + + if (start < 0) { + start = 0; + } + + if (end > buffer.length - 1) { + end = buffer.length - 1; + } + + return { + start: start, + end: end + }; +}; + +/* + start, end in pixels +*/ +TrackEditor.prototype.setSelectedArea = function(start, end, shiftKey) { + var left, + right, + currentStart, + currentEnd; + + //extending selected area since shift is pressed. + if (shiftKey && (end - start === 0) && (this.prevSelectedArea !== undefined)) { + + currentStart = this.samplesToPixels(this.prevSelectedArea.start); + currentEnd = this.samplesToPixels(this.prevSelectedArea.end); + + if (start < currentStart) { + left = start; + right = currentEnd; + } + else if (end > currentEnd) { + left = currentStart; + right = end; + } + //it's ambigous otherwise, cut off the smaller duration. + else { + if ((start - currentStart) < (currentEnd - start)) { + left = start; + right = currentEnd; + } + else { + left = currentStart; + right = end; + } + } + } + else { + left = start; + right = end; + } + + this.prevSelectedArea = this.selectedArea; + this.selectedArea = this.adjustSelectedArea(this.pixelsToSamples(left), this.pixelsToSamples(right)); +}; + +TrackEditor.prototype.activateAudioSelection = function() { + + this.fire("activateSelection"); +}; + +TrackEditor.prototype.deactivateAudioSelection = function() { + + this.fire("deactivateSelection"); +}; + +TrackEditor.prototype.selectStart = function(e) { + var el = e.currentTarget, //want the events placed on the channel wrapper. + editor = this, + //scroll = this.config.getTrackScroll(), + //scrollX = scroll.left, + //startX = scrollX + (e.layerX || e.offsetX), //relative to e.target (want the canvas). + //prevX = scrollX + (e.layerX || e.offsetX), + startX = e.layerX || e.offsetX, //relative to e.target (want the canvas). + prevX = e.layerX || e.offsetX, + offset = this.leftOffset, + startTime; + + if (e.target.tagName !== "CANVAS") { + return; + } + + editor.setSelectedArea(startX, startX); + startTime = editor.samplesToSeconds(offset + editor.selectedArea.start); + + editor.updateEditor(-1, undefined, undefined, true); + editor.notifySelectUpdate(startTime, startTime); + + //dynamically put an event on the element. + el.onmousemove = function(e) { + var currentX = e.layerX || e.offsetX, + //currentX = scrollX + (e.layerX || e.offsetX), + delta = currentX - prevX, + minX = Math.min(prevX, currentX, startX), + maxX = Math.max(prevX, currentX, startX), + selectStart, + selectEnd, + startTime, endTime; + + if (currentX > startX) { + selectStart = startX; + selectEnd = currentX; + } + else { + selectStart = currentX; + selectEnd = startX; + } + + startTime = editor.samplesToSeconds(offset + editor.selectedArea.start); + endTime = editor.samplesToSeconds(offset + editor.selectedArea.end); + + editor.setSelectedArea(selectStart, selectEnd); + editor.updateEditor(-1, undefined, undefined, true); + editor.notifySelectUpdate(startTime, endTime); + prevX = currentX; + }; + el.onmouseup = function(e) { + var endX = e.layerX || e.offsetX, + //endX = scrollX + (e.layerX || e.offsetX), + minX, maxX, + cursorPos, + startTime, endTime; + + minX = Math.min(startX, endX); + maxX = Math.max(startX, endX); + + editor.setSelectedArea(minX, maxX, e.shiftKey); + + minX = editor.samplesToPixels(offset + editor.selectedArea.start); + maxX = editor.samplesToPixels(offset + editor.selectedArea.end); + + el.onmousemove = document.body.onmouseup = null; + + //if more than one pixel is selected, listen to possible fade events. + if (Math.abs(minX - maxX)) { + editor.activateAudioSelection(); + } + else { + editor.deactivateAudioSelection(); + } + + cursorPos = startTime = editor.samplesToSeconds(offset + editor.selectedArea.start); + endTime = editor.samplesToSeconds(offset + editor.selectedArea.end); + + editor.updateEditor(-1, undefined, undefined, true); + editor.config.setCursorPos(cursorPos); + editor.notifySelectUpdate(startTime, endTime); + }; +}; + +/* end of state methods */ + +TrackEditor.prototype.saveFade = function(id, type, shape, start, end) { + + this.fades[id] = { + type: type, + shape: shape, + start: start, + end: end + }; + + return id; +}; + +TrackEditor.prototype.removeFade = function(id) { + + delete this.fades[id]; +}; + +/* + Cue points are stored internally in the editor as sample indices for highest precision. + + sample at index cueout is not included. +*/ +TrackEditor.prototype.setCuePoints = function(cuein, cueout) { + var offset = this.cues ? this.cues.cuein : 0; + + this.cues = { + cuein: offset + cuein, + cueout: offset + cueout + }; + + this.duration = (cueout - cuein) / this.sampleRate; + this.endTime = this.duration + this.startTime; +}; + +/* + Will remove all audio samples from the track's buffer except for the currently selected area. + Used to set cuein / cueout points in the audio. + + start, end are indices into the audio buffer and are inclusive. +*/ +TrackEditor.prototype.trim = function(start, end) { + + this.setCuePoints(start, end+1); + this.resetCursor(); + this.drawTrack(this.getBuffer()); +}; + + +/* + Will remove all audio samples from the track's buffer in the currently selected area. + + start, end are indices into the audio buffer and are inclusive. +*/ +TrackEditor.prototype.removeAudio = function(start, end) { + +}; + +TrackEditor.prototype.onTrackEdit = function(event) { + var type = event.type, + method = "on" + type.charAt(0).toUpperCase() + type.slice(1); + + if (this.active === true) { + this[method].call(this, event.args); + } +}; + +TrackEditor.prototype.onCreateFade = function(args) { + var selected = this.selectedArea, + pixelOffset = this.getPixelOffset(), + start = this.samplesToPixels(selected.start), + end = this.samplesToPixels(selected.end), + startTime = this.samplesToSeconds(selected.start), + endTime = this.samplesToSeconds(selected.end), + id = this.getFadeId(); + + this.resetCursor(); + this.saveFade(id, args.type, args.shape, startTime, endTime); + this.drawer.draw(-1, pixelOffset); + this.drawer.drawFade(id, args.type, args.shape, start, end); + + this.deactivateAudioSelection(); +}; + +TrackEditor.prototype.onZeroCrossing = function() { + var selected = this.getSelectedArea(), + startTime, + endTime, + offset = this.leftOffset; + + this.selectedArea = this.findNearestZeroCrossing(selected.start, selected.end); + + startTime = this.samplesToSeconds(offset + this.selectedArea.start); + endTime = this.samplesToSeconds(offset + this.selectedArea.end); + this.notifySelectUpdate(startTime, endTime); + this.updateEditor(-1, undefined, undefined, true); +}; + +TrackEditor.prototype.onTrimAudio = function() { + var selected = this.getSelectedArea(); + + this.trim(selected.start, selected.end); + this.deactivateAudioSelection(); +}; + +TrackEditor.prototype.onRemoveAudio = function() { + var selected = this.getSelectedArea(); + + this.removeAudio(selected.start, selected.end); + this.deactivateAudioSelection(); +}; + +TrackEditor.prototype.setState = function(state) { + var that = this, + stateEvents = this.states[state].events, + stateClasses = this.states[state].classes, + container = this.container, + prevState = this.currentState, + prevStateClasses, + prevStateEvents = this.prevStateEvents, + func, event, cl, + i, len; + + if (prevState) { + prevStateClasses = this.states[prevState].classes; + + for (event in prevStateEvents) { + container.removeEventListener(event, prevStateEvents[event]); + } + this.prevStateEvents = {}; + + for (i = 0, len = prevStateClasses.length; i < len; i++) { + container.classList.remove(prevStateClasses[i]); + } + } + + for (event in stateEvents) { + + func = that[stateEvents[event]].bind(that); + //need to keep track of the added events for later removal since a new function is returned after using "bind" + this.prevStateEvents[event] = func; + container.addEventListener(event, func); + } + for (i = 0, len = stateClasses.length; i < len; i++) { + container.classList.add(stateClasses[i]); + } + + this.currentState = state; +}; + +TrackEditor.prototype.onStateChange = function() { + var state = this.config.getState(); + + this.setState(state); +}; + +TrackEditor.prototype.onResolutionChange = function(res) { + var selected = this.selectedArea; + + this.resolution = res; + this.drawTrack(this.getBuffer()); + + if (this.active === true && this.selectedArea !== undefined) { + + this.updateEditor(-1, this.samplesToPixels(selected.start), this.samplesToPixels(selected.end), true); + } +}; + +TrackEditor.prototype.isPlaying = function() { + return this.playout.isScheduled() || this.playout.isPlaying(); +}; + +/* + startTime, endTime in seconds (float). +*/ +TrackEditor.prototype.schedulePlay = function(now, delay, startTime, endTime) { + var start, + duration, + relPos, + when = now + delay, + window = (endTime) ? (endTime - startTime) : undefined, + cueOffset = this.cues.cuein / this.sampleRate; + + //track has no content to play. + if (this.endTime <= startTime) return; + + //track does not start in this selection. + if (window && (startTime + window) < this.startTime) return; + + + //track should have something to play if it gets here. + + //the track starts in the future of the cursor position + if (this.startTime >= startTime) { + start = 0; + when = when + this.startTime - startTime; //schedule additional delay for this audio node. + window = window - (this.startTime - startTime); + duration = (endTime) ? Math.min(window, this.duration) : this.duration; + } + else { + start = startTime - this.startTime; + duration = (endTime) ? Math.min(window, this.duration - start) : this.duration - start; + } + + start = start + cueOffset; + + relPos = startTime - this.startTime; + this.playout.applyFades(this.fades, relPos, now, delay); + this.playout.play(when, start, duration); +}; + +TrackEditor.prototype.scheduleStop = function(when) { + + this.playout.stop(when); +}; + +TrackEditor.prototype.resetCursor = function() { + this.selectedArea = undefined; + this.config.setCursorPos(0); + this.notifySelectUpdate(0, 0); +}; + +TrackEditor.prototype.updateEditor = function(cursorPos, start, end, highlighted) { + var pixelOffset = this.getPixelOffset(), + selected; + + if (this.selectedArea) { + //must pass selected area in pixels. + selected = { + start: this.samplesToPixels(this.selectedArea.start), + end: this.samplesToPixels(this.selectedArea.end) + }; + } + + this.drawer.updateEditor(cursorPos, pixelOffset, start, end, highlighted, selected); +}; + +TrackEditor.prototype.getTrackDetails = function() { + var d, + cues = this.cues; + + d = { + start: this.startTime, + end: this.endTime, + fades: this.fades, + src: this.src, + cuein: this.samplesToSeconds(cues.cuein), + cueout: this.samplesToSeconds(cues.cueout) + }; + + return d; +}; + diff --git a/airtime_mvc/public/js/waveformplaylist/track_render.js b/airtime_mvc/public/js/waveformplaylist/track_render.js new file mode 100644 index 000000000..611dfcbc0 --- /dev/null +++ b/airtime_mvc/public/js/waveformplaylist/track_render.js @@ -0,0 +1,430 @@ +'use strict'; + +var WaveformDrawer = function() { + +}; + +WaveformDrawer.prototype.init = function(container, config) { + + makePublisher(this); + + this.config = config; + this.container = container; + this.channels = []; //array of canvases, contexts, 1 for each channel displayed. + + var theme = this.config.getUITheme(); + + if (this.loaderStates[theme] !== undefined) { + this.loaderStates = this.loaderStates[theme]; + } + else { + this.loaderStates = this.loaderStates["default"]; + } +}; + +WaveformDrawer.prototype.loaderStates = { + "bootstrap": { + "downloading": "progress progress-warning", + "decoding": "progress progress-success progress-striped active", + "loader": "bar" + }, + + "jQueryUI": { + "downloading": "ui-progressbar ui-widget ui-widget-content ui-corner-all", + "decoding": "ui-progressbar ui-widget ui-widget-content ui-corner-all", + "loader": "ui-progressbar-value ui-widget-header ui-corner-left" + }, + + "default": { + "downloading": "progress", + "decoding": "decoding", + "loader": "bar" + } +}; + +WaveformDrawer.prototype.getPeaks = function(buffer, cues) { + + // Frames per pixel + var res = this.config.getResolution(), + peaks = [], + i, c, p, l, + chanLength = cues.cueout - cues.cuein, + pixels = Math.ceil(chanLength / res), + numChan = buffer.numberOfChannels, + weight = 1 / (numChan), + makeMono = this.config.isDisplayMono(), + chan, + start, + end, + vals, + max, + min, + maxPeak = -Infinity; //used to scale the waveform on the canvas. + + for (i = 0; i < pixels; i++) { + + peaks[i] = []; + + for (c = 0; c < numChan; c++) { + + chan = buffer.getChannelData(c); + chan = chan.subarray(cues.cuein, cues.cueout); + + start = i * res; + end = (i + 1) * res > chanLength ? chanLength : (i + 1) * res; + vals = chan.subarray(start, end); + max = -Infinity; + min = Infinity; + + for (p = 0, l = vals.length; p < l; p++) { + if (vals[p] > max){ + max = vals[p]; + } + if (vals[p] < min){ + min = vals[p]; + } + } + peaks[i].push({max:max, min:min}); + maxPeak = Math.max.apply(Math, [maxPeak, Math.abs(max), Math.abs(min)]); + } + + if (makeMono) { + max = min = 0; + + for (c = 0 ; c < numChan; c++) { + max = max + weight * peaks[i][c].max; + min = min + weight * peaks[i][c].min; + } + + peaks[i] = []; //need to clear out old stuff (maybe we should keep it for toggling views?). + peaks[i].push({max:max, min:min}); + } + } + + this.maxPeak = maxPeak; + this.peaks = peaks; +}; + +WaveformDrawer.prototype.setTimeShift = function(pixels) { + var i, len; + + for (i = 0, len = this.channels.length; i < len; i++) { + this.channels[i].div.style.left = pixels+"px"; + } +}; + +WaveformDrawer.prototype.updateLoader = function(percent) { + this.loader.style.width = percent+"%"; +}; + +WaveformDrawer.prototype.setLoaderState = function(state) { + this.progressDiv.className = this.loaderStates[state]; +}; + +WaveformDrawer.prototype.drawLoading = function() { + var div, + loader; + + this.height = this.config.getWaveHeight(); + + div = document.createElement("div"); + div.style.height = this.height+"px"; + + loader = document.createElement("div"); + loader.style.height = "10px"; + loader.className = this.loaderStates["loader"]; + + div.appendChild(loader); + + this.progressDiv = div; + this.loader = loader; + + this.setLoaderState("downloading"); + this.updateLoader(0); + + this.container.appendChild(div); +}; + +WaveformDrawer.prototype.drawBuffer = function(buffer, pixelOffset, cues) { + var canv, + div, + i, + top = 0, + left = 0, + makeMono = this.config.isDisplayMono(), + res = this.config.getResolution(), + numChan = makeMono? 1 : buffer.numberOfChannels, + numSamples = cues.cueout - cues.cuein + 1, + fragment = document.createDocumentFragment(), + wrapperHeight; + + this.container.innerHTML = ""; + this.channels = []; + + //width and height is per waveform canvas. + this.width = Math.ceil(numSamples / res); + this.height = this.config.getWaveHeight(); + + for (i = 0; i < numChan; i++) { + + div = document.createElement("div"); + div.classList.add("channel"); + div.classList.add("channel-"+i); + div.style.width = this.width+"px"; + div.style.height = this.height+"px"; + div.style.top = top+"px"; + div.style.left = left+"px"; + + canv = document.createElement("canvas"); + canv.setAttribute('width', this.width); + canv.setAttribute('height', this.height); + + this.channels.push({ + canvas: canv, + context: canv.getContext('2d'), + div: div + }); + + div.appendChild(canv); + fragment.appendChild(div); + + top = top + this.height; + } + + wrapperHeight = numChan * this.height; + this.container.style.height = wrapperHeight+"px"; + this.container.appendChild(fragment); + + + this.getPeaks(buffer, cues); + this.updateEditor(); + + this.setTimeShift(pixelOffset); +}; + +WaveformDrawer.prototype.drawFrame = function(chanNum, index, peaks, maxPeak, cursorPos, pixelOffset) { + var x, y, w, h, max, min, + h2 = this.height / 2, + cc = this.channels[chanNum].context, + colors = this.config.getColorScheme(); + + max = (peaks.max / maxPeak) * h2; + min = (peaks.min / maxPeak) * h2; + + w = 1; + x = index * w; + y = Math.round(h2 - max); + h = Math.ceil(max - min); + + //to prevent blank space when there is basically silence in the track. + h = h === 0 ? 1 : h; + + if (cursorPos >= (x + pixelOffset)) { + cc.fillStyle = colors.progressColor; + } + else { + cc.fillStyle = colors.waveColor; + } + + cc.fillRect(x, y, w, h); +}; + +/* + start, end are optional parameters to only redraw part of the canvas. +*/ +WaveformDrawer.prototype.draw = function(cursorPos, pixelOffset, start, end) { + var that = this, + peaks = this.peaks, + i = (start) ? start - pixelOffset : 0, + len = (end) ? end - pixelOffset + 1 : peaks.length; + + if (i < 0 && len < 0) { + return; + } + + if (i < 0) { + i = 0; + } + + if (len > peaks.length) { + len = peaks.length; + } + + this.clear(i, len); + + for (; i < len; i++) { + + peaks[i].forEach(function(peak, chanNum) { + that.drawFrame(chanNum, i, peak, that.maxPeak, cursorPos, pixelOffset); + }); + } +}; + +/* + If start/end are set clear only part of the canvas. +*/ +WaveformDrawer.prototype.clear = function(start, end) { + var i, len, + width = end - start; + + for (i = 0, len = this.channels.length; i < len; i++) { + this.channels[i].context.clearRect(start, 0, width, this.height); + } +}; + +WaveformDrawer.prototype.updateEditor = function(cursorPos, pixelOffset, start, end, highlighted, selected) { + var i, len, + fragment = document.createDocumentFragment(); + + this.container.innerHTML = ""; + + this.draw(cursorPos, pixelOffset, start, end); + + if (highlighted === true && selected !== undefined) { + var border = (selected.end - selected.start === 0) ? true : false; + this.drawHighlight(selected.start, selected.end, border); + } + + for (i = 0, len = this.channels.length; i < len; i++) { + fragment.appendChild(this.channels[i].div); + } + + this.container.appendChild(fragment); +}; + +/* + start, end in pixels. +*/ +WaveformDrawer.prototype.drawHighlight = function(start, end, isBorder) { + var i, len, + colors = this.config.getColorScheme(), + fillStyle, + ctx, + width = end - start + 1; + + fillStyle = (isBorder) ? colors.selectBorderColor : colors.selectBackgroundColor; + + for (i = 0, len = this.channels.length; i < len; i++) { + ctx = this.channels[i].context; + ctx.fillStyle = fillStyle; + ctx.fillRect(start, 0, width, this.height); + } +}; + +WaveformDrawer.prototype.sCurveFadeIn = function sCurveFadeIn(ctx, width) { + return Curves.createSCurveBuffer(width, (Math.PI/2)); +}; + +WaveformDrawer.prototype.sCurveFadeOut = function sCurveFadeOut(ctx, width) { + return Curves.createSCurveBuffer(width, -(Math.PI/2)); +}; + +WaveformDrawer.prototype.logarithmicFadeIn = function logarithmicFadeIn(ctx, width) { + return Curves.createLogarithmicBuffer(width, 10, 1); +}; + +WaveformDrawer.prototype.logarithmicFadeOut = function logarithmicFadeOut(ctx, width) { + return Curves.createLogarithmicBuffer(width, 10, -1); +}; + +WaveformDrawer.prototype.exponentialFadeIn = function exponentialFadeIn(ctx, width) { + return Curves.createExponentialBuffer(width, 1); +}; + +WaveformDrawer.prototype.exponentialFadeOut = function exponentialFadeOut(ctx, width) { + return Curves.createExponentialBuffer(width, -1); +}; + +WaveformDrawer.prototype.linearFadeIn = function linearFadeIn(ctx, width) { + return Curves.createLinearBuffer(width, 1); +}; + +WaveformDrawer.prototype.linearFadeOut = function linearFadeOut(ctx, width) { + return Curves.createLinearBuffer(width, -1); +}; + +WaveformDrawer.prototype.drawFadeCurve = function(ctx, shape, type, width) { + var method = shape+type, + fn = this[method], + colors = this.config.getColorScheme(), + curve, + i, len, + cHeight = this.height, + y; + + ctx.strokeStyle = colors.fadeColor; + + curve = fn.call(this, ctx, width); + + y = cHeight - curve[0] * cHeight; + ctx.beginPath(); + ctx.moveTo(0, y); + + for (i = 1, len = curve.length; i < len; i++) { + y = cHeight - curve[i] * cHeight; + ctx.lineTo(i, y); + } + ctx.stroke(); +}; + + +WaveformDrawer.prototype.drawFade = function(id, type, shape, start, end) { + var div, + canv, + width, + left, + fragment = document.createDocumentFragment(), + i, len, + dup, + ctx, + tmpCtx; + + width = ~~(end - start + 1); + left = start; + + div = document.createElement("div"); + div.classList.add("playlist-fade"); + div.classList.add("playlist-fade-"+id); + div.style.width = width+"px"; + div.style.height = this.height+"px"; + div.style.top = 0; + div.style.left = left+"px"; + + canv = document.createElement("canvas"); + canv.setAttribute('width', width); + canv.setAttribute('height', this.height); + ctx = canv.getContext('2d'); + + this.drawFadeCurve(ctx, shape, type, width); + + div.appendChild(canv); + fragment.appendChild(div); + + for (i = 0, len = this.channels.length; i < len; i++) { + dup = fragment.cloneNode(true); + tmpCtx = dup.querySelector('canvas').getContext('2d'); + tmpCtx.drawImage(canv, 0, 0); + + this.channels[i].div.appendChild(dup); + } +}; + +WaveformDrawer.prototype.drawFades = function(fades) { + var id, + fade, + startPix, + endPix, + SR = this.config.getSampleRate(), + res = this.config.getResolution(); + + for (id in fades) { + fade = fades[id]; + + if (fades.hasOwnProperty(id)) { + startPix = fade.start * SR / res; + endPix = fade.end * SR / res; + this.drawFade(id, fade.type, fade.shape, startPix, endPix); + } + } +}; +