├── .gitignore ├── LICENSE ├── README.md ├── index.html ├── js ├── SoundManager.js └── SyncLoop.js └── style.css /.gitignore: -------------------------------------------------------------------------------- 1 | poplar/ 2 | ttgl/ 3 | working/ 4 | wakaba/ 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 William Toohey 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SyncLoop 2 | Frame perfect audio+animation sync. Like YTMND but better. 3 | 4 | Uses the Web Audio API and HTML5 canvas to sync animations to songs. See examples for config. 5 | 6 | Some examples: 7 | [Certified Working Material](http://loop.mon.im/working.html) 8 | [Am I Poplar Yet?](http://loop.mon.im/) 9 | [Life In a Nutshell](http://loop.mon.im/LifeInANutshell.html) 10 | 11 | All examples are converted from wonderful flash loops by Pyure and AMM. 12 | 13 | The videosync branch has very rough test code for using HTML5 video elements instead of canvas. It works very well on desktop by dynamically scaling playbackRate, but has 0 support on mobile, so I dropped it. 14 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Am I Poplar Yet? 6 | 7 | 8 | 9 | 28 | 29 | 30 |
31 |
32 |
0%
33 |
34 |
35 |
36 | 37 | 38 | -------------------------------------------------------------------------------- /js/SoundManager.js: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2015 William Toohey 2 | * 3 | * Permission is hereby granted, free of charge, to any person obtaining a copy 4 | * of this software and associated documentation files (the "Software"), to deal 5 | * in the Software without restriction, including without limitation the rights 6 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | * copies of the Software, and to permit persons to whom the Software is 8 | * furnished to do so, subject to the following conditions: 9 | * 10 | * The above copyright notice and this permission notice shall be included in 11 | * all copies or substantial portions of the Software. 12 | * 13 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | * THE SOFTWARE. 20 | */ 21 | 22 | // Flash value + MAGIC WEB VALUE 23 | var LAME_DELAY_START = 2258; 24 | var LAME_DELAY_END = 1000; 25 | 26 | function SoundManager() { 27 | this.playing = false; 28 | 29 | /* Lower level audio and timing info */ 30 | this.bufSource = null; 31 | this.buffer = null; 32 | this.context = null; // Audio context, Web Audio API 33 | this.startTime = 0; // File start time - 0 is loop start, not build start 34 | 35 | // Volume 36 | this.gainNode = null; 37 | this.mute = false; 38 | this.lastVol = 1; 39 | 40 | // In case of API non-support 41 | this.canUse = true; 42 | 43 | // Check Web Audio API Support 44 | try { 45 | // More info at http://caniuse.com/#feat=audio-api 46 | window.AudioContext = window.AudioContext || window.webkitAudioContext; 47 | this.context = new window.AudioContext(); 48 | this.gainNode = this.context.createGain(); 49 | this.gainNode.connect(this.context.destination); 50 | } catch(e) { 51 | this.canUse = false; 52 | this.errorMsg = "Web Audio API not supported in this browser."; 53 | return; 54 | } 55 | var audio = document.createElement("audio"), 56 | canPlayMP3 = (typeof audio.canPlayType === "function" && 57 | audio.canPlayType("audio/mpeg") !== ""); 58 | if(!canPlayMP3) { 59 | this.canUse = false; 60 | this.errorMsg = "MP3 not supported in this browser."; 61 | return; 62 | } 63 | 64 | this.locked = this.context.state != "running"; 65 | } 66 | 67 | SoundManager.prototype.unlock = function() { 68 | if(this.lockedPromise) { 69 | return this.lockedPromise; 70 | } 71 | var that = this; 72 | this.lockedPromise = new Promise(function(resolve) { 73 | // iOS and other some mobile browsers - unlock the context as 74 | // it starts in a suspended state 75 | function unlocker() { 76 | // create empty buffer 77 | var buffer = that.context.createBuffer(1, 1, 22050); 78 | var source = that.context.createBufferSource(); 79 | source.buffer = buffer; 80 | 81 | // connect to output (your speakers) 82 | source.connect(that.context.destination); 83 | 84 | // play the file 85 | source.start(0); 86 | 87 | window.removeEventListener('touchend', unlocker); 88 | window.removeEventListener('click', unlocker); 89 | resolve(); 90 | } 91 | window.addEventListener('touchend', unlocker, false); 92 | window.addEventListener('click', unlocker, false); 93 | }); 94 | return this.lockedPromise; 95 | } 96 | 97 | SoundManager.prototype.playSong = function(arrayBuffer, callback) { 98 | var that = this; 99 | this.stop(); 100 | 101 | this.context.decodeAudioData(arrayBuffer, function(buffer) { 102 | that.buffer = that.trimMP3(buffer); 103 | that.bufSource = that.context.createBufferSource(); 104 | that.bufSource.buffer = that.buffer; 105 | that.bufSource.loop = true; 106 | that.bufSource.connect(that.gainNode); 107 | 108 | // This fixes sync issues on Firefox and slow machines. 109 | if(that.context.suspend && that.context.resume) { 110 | that.context.suspend().then(function() { 111 | that.bufSource.start(0); 112 | that.startTime = that.context.currentTime; 113 | that.context.resume().then(function() { 114 | that.playing = true; 115 | if(callback) { 116 | callback(); 117 | } 118 | }); 119 | }); 120 | } else { 121 | that.bufSource.start(0); 122 | that.startTime = that.context.currentTime; 123 | that.playing = true; 124 | if(callback) { 125 | callback(); 126 | } 127 | } 128 | }, function() { 129 | console.log('Error decoding audio.'); 130 | }); 131 | } 132 | 133 | SoundManager.prototype.stop = function() { 134 | if (this.playing) { 135 | // arg required for mobile webkit 136 | this.bufSource.stop(0); 137 | this.bufSource.disconnect(); // TODO needed? 138 | this.bufSource = null; 139 | this.playing = false; 140 | this.startTime = 0; 141 | } 142 | } 143 | 144 | // In seconds, relative to the loop start 145 | SoundManager.prototype.currentTime = function() { 146 | if(!this.playing) { 147 | return 0; 148 | } 149 | return this.context.currentTime - this.startTime; 150 | } 151 | 152 | SoundManager.prototype.currentProgress = function() { 153 | return this.currentTime() / this.buffer.duration; 154 | } 155 | 156 | // because MP3 is bad, we nuke silence 157 | SoundManager.prototype.trimMP3 = function(buffer) { 158 | // Firefox < 71 has to trim always since it had a broken mp3 decoder 159 | var isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1; 160 | if(!(isFirefox)) { 161 | return buffer; 162 | } 163 | 164 | var match = window.navigator.userAgent.match(/Firefox\/([0-9]+)\./); 165 | var ver = match ? parseInt(match[1]) : 0; 166 | if(ver >= 71) { 167 | return buffer; 168 | } 169 | 170 | var start = LAME_DELAY_START; 171 | var newLength = buffer.length - LAME_DELAY_START - LAME_DELAY_END; 172 | var ret = this.context.createBuffer(buffer.numberOfChannels, newLength, buffer.sampleRate); 173 | for(var i=0; i 2 | * 3 | * Permission is hereby granted, free of charge, to any person obtaining a copy 4 | * of this software and associated documentation files (the "Software"), to deal 5 | * in the Software without restriction, including without limitation the rights 6 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | * copies of the Software, and to permit persons to whom the Software is 8 | * furnished to do so, subject to the following conditions: 9 | * 10 | * The above copyright notice and this permission notice shall be included in 11 | * all copies or substantial portions of the Software. 12 | * 13 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | * THE SOFTWARE. 20 | */ 21 | 22 | SyncLoop = function(defaults) { 23 | this.defaults = defaults; 24 | this.toLoad = 0; 25 | this.loadProgress = []; 26 | this.audio = null; 27 | this.images = []; 28 | this.canvas = document.getElementById(defaults.canvasID).getContext("2d"); 29 | this.lastFrame = -1; 30 | this.frames = []; 31 | 32 | // For custom song timing 33 | this.lastBeat = -1; 34 | this.beatOffset = 0; 35 | // For custom animation timing 36 | this.animTiming = []; 37 | 38 | var that = this; 39 | window.onerror = function(msg, url, line, col, error) { 40 | that.error(msg); 41 | // Get more info in console 42 | return false; 43 | }; 44 | window.onresize = function() { 45 | that.resize(); 46 | } 47 | 48 | console.log("SyncLoop init..."); 49 | this.soundManager = new SoundManager(this); 50 | if(!this.soundManager.canUse) { 51 | this.error(this.soundManager.errorMsg); 52 | return; 53 | } 54 | 55 | if(this.soundManager.locked) { 56 | document.getElementById("preSub").textContent = "Click to unlock audio playback"; 57 | this.soundManager.unlock().then(function() { 58 | document.getElementById("preSub").textContent = ""; 59 | }); 60 | } 61 | 62 | this.loadResources(); 63 | 64 | document.onkeydown = function(e){ 65 | e = e || window.event; 66 | // Ignore modifiers so we don't steal other events 67 | if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) { 68 | return true; 69 | } 70 | var key = e.keyCode || e.which; 71 | return that.keyHandler(key); 72 | }; 73 | 74 | this.animationLoop(); 75 | } 76 | 77 | SyncLoop.prototype.loadResources = function(callback) { 78 | // anim frames + song file 79 | this.toLoad = this.defaults.animation.frames + 1; 80 | this.loadProgress = []; 81 | for(var i = 0; i < this.toLoad; i++) { 82 | this.loadProgress[i] = 0; 83 | } 84 | 85 | this.loadSong(this.defaults.song.filename); 86 | 87 | var img = this.defaults.animation.filename; 88 | // NOTE: 1 indexed 89 | for(var i = 1; i <= this.defaults.animation.frames; i++) { 90 | var n = i + ''; 91 | var width = this.defaults.animation.frameTextPadding; 92 | if(width) { 93 | n = n.length >= width ? n : new Array(width - n.length + 1).join('0') + n; 94 | } 95 | this.loadImage(img.replace("%FRAME%", n), i); 96 | } 97 | } 98 | 99 | SyncLoop.prototype.loadComplete = function() { 100 | this.updateProgress(); 101 | if(--this.toLoad <= 0) { 102 | this.resize(); 103 | // for weird loop back/forwards animations 104 | var keyframes = this.defaults.animation.frameKeyframes; 105 | if(keyframes) { 106 | //this.frames.push(keyframes[0]-1); // first frame inclusive 107 | for(var i = 0; i < keyframes.length-1; i++) { 108 | var a = keyframes[i]; 109 | var b = keyframes[i+1]; 110 | if(b < a) { 111 | for(var j = a; j > b; j--) { 112 | this.frames.push(j-1); 113 | } 114 | } else { 115 | for(var j = a; j < b; j++) { 116 | this.frames.push(j-1); 117 | } 118 | } 119 | } 120 | this.frames.push(j-1); // last frame inclusive 121 | } else { 122 | for(var i = 0; i < this.defaults.animation.frames; i++) { 123 | this.frames.push(i); 124 | } 125 | } 126 | // back and forth, forever 127 | if(this.defaults.animation.pingpong) { 128 | this.frames = this.frames.concat(this.frames.slice(1).reverse().slice(1)); 129 | } 130 | var beats = this.defaults.animation.beats; 131 | if(beats) { 132 | var i = 0; 133 | for(i = 1; i <= beats.length; i++) { 134 | var tail = undefined; 135 | if(i != beats.length) { 136 | tail = beats[i]-1; 137 | } 138 | this.animTiming.push(this.frames.slice(beats[i-1]-1, tail)); 139 | } 140 | for(i = 1; i < beats[0]; i++) { 141 | this.animTiming[this.animTiming.length-1].push(this.frames[i-1]); 142 | } 143 | } 144 | this.soundManager.playSong(this.audio, function() { 145 | document.getElementById("preloadHelper").className = "loaded"; 146 | }); 147 | } 148 | } 149 | 150 | SyncLoop.prototype.loadImage = function(url, index) { 151 | var that = this; 152 | img = new Image(); 153 | img.onload = function() { 154 | that.loadProgress[index] = 1; 155 | that.loadComplete(); 156 | }; 157 | img.src = url; 158 | // account for 1 indexing 159 | this.images[index-1] = img; 160 | } 161 | 162 | SyncLoop.prototype.loadSong = function(url) { 163 | var that = this; 164 | var req = new XMLHttpRequest(); 165 | req.open('GET', url, true); 166 | req.responseType = 'arraybuffer'; 167 | req.onload = function() { 168 | that.audio = req.response; 169 | that.loadProgress[0] = 1; 170 | that.loadComplete(); 171 | }; 172 | req.onerror = function() { 173 | console.log("Could not load loop audio at URL", url); 174 | } 175 | req.onprogress = function(event) { 176 | if (event.lengthComputable) { 177 | var percent = event.loaded / event.total; 178 | that.loadProgress[0] = percent; 179 | that.updateProgress(); 180 | } else { 181 | // Unable to compute progress information since the total size is unknown 182 | } 183 | } 184 | req.send(); 185 | } 186 | 187 | SyncLoop.prototype.updateProgress = function() { 188 | var sum = this.loadProgress.reduce(function(a, b){return a+b;}); 189 | var percent = sum / this.loadProgress.length; 190 | var prog = document.getElementById("preMain"); 191 | var scale = Math.floor(percent * 100); 192 | prog.textContent = scale + "%"; 193 | } 194 | 195 | SyncLoop.prototype.animationLoop = function() { 196 | var that = this; 197 | requestAnimationFrame(function() {that.animationLoop()}); 198 | if(!this.soundManager.playing) { 199 | return; 200 | } 201 | 202 | var songBeat = this.getSongBeat(); 203 | var frame = this.getAnimFrame(songBeat); 204 | var imgFrame = this.frames[frame]; 205 | 206 | if(imgFrame != this.lastFrame) { 207 | this.lastFrame = imgFrame; 208 | // Clear 209 | this.canvas.canvas.width = this.canvas.canvas.width; 210 | this.canvas.drawImage(this.images[imgFrame], 0, 0, this.canvas.canvas.width, this.canvas.canvas.height); 211 | } 212 | } 213 | 214 | SyncLoop.prototype.getSongBeat = function() { 215 | var songBeats = this.defaults.song.beatsPerLoop || this.defaults.song.beatmap.length; 216 | var songBeat = songBeats * this.soundManager.currentProgress(); 217 | var beatmap = this.defaults.song.beatmap; 218 | if(beatmap) { 219 | while(this.lastBeat < songBeat) { 220 | if(beatmap[Math.floor(this.lastBeat++ % songBeats)] == ".") { 221 | this.beatOffset++; 222 | } 223 | } 224 | if(beatmap[Math.floor(this.lastBeat % songBeats)] == ".") { 225 | songBeat = Math.floor(songBeat); 226 | } 227 | } 228 | return songBeat - this.beatOffset; 229 | } 230 | 231 | SyncLoop.prototype.getAnimFrame = function(songBeat) { 232 | var frame = 0; 233 | var animBeats = this.defaults.animation.beatsPerLoop || this.animTiming.length; 234 | if(this.animTiming.length) { // custom beat endpoints 235 | songBeat %= animBeats; 236 | var beatFrames = this.animTiming[Math.floor(songBeat)]; 237 | frame = beatFrames[Math.floor(beatFrames.length * (songBeat % 1))]; 238 | } else { 239 | var animProgress = (this.frames.length / animBeats) * songBeat; 240 | frame = Math.floor(animProgress); 241 | } 242 | 243 | if(this.defaults.animation.syncOffset) { 244 | frame += this.defaults.animation.syncOffset; 245 | } 246 | 247 | frame = this.mod(frame, this.frames.length); 248 | 249 | return frame; 250 | } 251 | 252 | SyncLoop.prototype.mod = function(num, n) { 253 | return ((num%n)+n)%n; 254 | } 255 | 256 | SyncLoop.prototype.resize = function() { 257 | if(this.toLoad > 0) { 258 | return; 259 | } 260 | var elem = this.canvas.canvas; 261 | 262 | // Set unscaled 263 | var width = this.images[0].width; 264 | var height = this.images[0].height; 265 | var ratio = width / height; 266 | 267 | var scaleWidth = window.innerHeight * ratio; 268 | if(scaleWidth > window.innerWidth) { 269 | elem.height = Math.floor(window.innerWidth / ratio); 270 | elem.width = Math.floor(window.innerWidth); 271 | } else { 272 | elem.height = Math.floor(window.innerHeight); 273 | elem.width = Math.floor(scaleWidth); 274 | } 275 | elem.style.height = elem.height + "px"; 276 | elem.style.width = elem.width + "px"; 277 | 278 | elem.style.marginLeft = (window.innerWidth - elem.width) / 2 + "px"; 279 | elem.style.marginTop = (window.innerHeight - elem.height) / 2 + "px"; 280 | } 281 | 282 | SyncLoop.prototype.keyHandler = function(key) { 283 | switch (key) { 284 | case 109: // NUMPAD_SUBTRACT 285 | case 189: // MINUS 286 | case 173: // MINUS, legacy 287 | this.soundManager.decreaseVolume(); 288 | break; 289 | case 107: // NUMPAD_ADD 290 | case 187: // EQUAL 291 | case 61: // EQUAL, legacy 292 | this.soundManager.increaseVolume(); 293 | break; 294 | case 77: // M 295 | this.soundManager.toggleMute(); 296 | break; 297 | default: 298 | return true; 299 | } 300 | return false; 301 | } 302 | 303 | SyncLoop.prototype.error = function(message) { 304 | document.getElementById("preSub").textContent = "Error: " + message; 305 | document.getElementById("preMain").style.color = "#F00"; 306 | } 307 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | width: 100%; 3 | height: 100%; 4 | margin: 0px; 5 | overflow: hidden; 6 | background-color: black; 7 | } 8 | 9 | #preloadHelper { 10 | background-color: #FFF; 11 | width: 100%; 12 | height: 100%; 13 | display:-webkit-flex; 14 | display:flex; 15 | -webkit-justify-content:center; 16 | justify-content:center; 17 | -webkit-align-items:center; 18 | align-items:center; 19 | -webkit-flex-direction: column; 20 | flex-direction: column; 21 | font-size: 25pt; 22 | 23 | position: absolute; 24 | top: 0; 25 | left: 0; 26 | z-index: 10; 27 | visibility: visible; 28 | opacity: 1; 29 | transition: visibility 1s linear, opacity 1s linear; 30 | -webkit-transition: visibility 1s linear, opacity 1s linear; 31 | } 32 | 33 | #preloadHelper.loaded { 34 | visibility: hidden; 35 | opacity: 0; 36 | } 37 | 38 | #preloader { 39 | display: block; 40 | text-align: center; 41 | } 42 | 43 | #preSub { 44 | font-size: 12pt; 45 | } --------------------------------------------------------------------------------