├── .gitignore ├── src ├── configProvider.js ├── copyToChannelPolyfill.js ├── browserCapabilities.js ├── webAudioUnlock.js ├── gestureEngine.js ├── resampler.js ├── gui.js └── main.js ├── rollup.config.js ├── package.json ├── index.html ├── LICENSE ├── README.md ├── player.css └── dist ├── bundle.min.js └── bundle.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .idea 3 | yarn-error.log -------------------------------------------------------------------------------- /src/configProvider.js: -------------------------------------------------------------------------------- 1 | module.exports.STREAMING_MIN_RESPONSE = 2**19; -------------------------------------------------------------------------------- /src/copyToChannelPolyfill.js: -------------------------------------------------------------------------------- 1 | module.exports = function(buf, cid) { 2 | let outputBuffer = this.getChannelData(cid); 3 | for (let i = 0; i < buf.length; i++) { 4 | outputBuffer[i] = buf[i]; 5 | } 6 | } -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import { terser } from 'rollup-plugin-terser'; 2 | import resolve from '@rollup/plugin-node-resolve'; 3 | import commonjs from '@rollup/plugin-commonjs'; 4 | 5 | export default { 6 | input: 'src/main.js', 7 | output: [ 8 | { 9 | file: 'dist/bundle.js', 10 | format: 'iife' 11 | }, 12 | { 13 | file: 'dist/bundle.min.js', 14 | format: 'iife', 15 | plugins: [terser()] 16 | } 17 | ], 18 | plugins: [ 19 | resolve(), 20 | commonjs({transformMixedEsModules:true}) 21 | ] 22 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "revolving-door", 3 | "version": "1.0.0", 4 | "description": "A web-based BRSTM player", 5 | "main": "index.js", 6 | "repository": "https://github.com/rphsoftware/revolving-door.git", 7 | "author": "rph ", 8 | "license": "GPL-2.0", 9 | "devDependencies": { 10 | "@rollup/plugin-commonjs": "^17.0.0", 11 | "@rollup/plugin-node-resolve": "^11.0.1", 12 | "rollup-plugin-terser": "^7.0.2" 13 | }, 14 | "scripts": { 15 | "build": "rollup --config rollup.config.js" 16 | }, 17 | "dependencies": { 18 | "brstm": "^1.4.0", 19 | "rollup": "^2.45.2" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Test 6 | 7 | 8 | 9 | Song id: 10 | asdf 11 | 12 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 Rph Software 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Revolving door 2 | 3 | A modern web player for Binary Revolution Stream (BRSTM) files 4 | 5 | ## Compiling 6 | 7 | This program is built using rollup. To set up a dev environment, simply run `yarn` and to compile run `npm run build`. The compiled js is then dropped into `dist/` as `bundle.min.js` 8 | 9 | ## Operation 10 | 11 | This program exposes a very basic API to the window context with just one function to initiate playback. `window.player.play(URL)`. Afterwards, everything is handled by gui. 12 | 13 | To properly add this program to your website, you also need to link the `player.css` stylesheet somewhere. 14 | 15 | ## Browser compatibility 16 | 17 | The program has been tested in all modern browsers (Chrome, Firefox, Safari) and has been known to also work on some older ones like Palemoon 28 and Icecat 60. 18 | 19 | The program attempts to adjust its functionality to what the browser requires (Either disabling or enabling the streaming features for example). 20 | 21 | ## Software used: 22 | 23 | This program uses `rollup` to compile and bundle itself ( https://rollupjs.org/) 24 | 25 | The `brstm` module by https://github.com/kenrick95 is used to decode BRSTM files (https://github.com/kenrick95/nikku/blob/master/src/brstm/ , https://www.npmjs.com/package/brstm , licensed under the MIT license) 26 | 27 | The audio resampler used by the program was written by Grant Galitz and was released to the Public Domain by the author. 28 | -------------------------------------------------------------------------------- /src/browserCapabilities.js: -------------------------------------------------------------------------------- 1 | module.exports = async function() { 2 | let capabilities = { 3 | sampleRate: false, 4 | streaming: false 5 | }; 6 | 7 | // Evaluate webaudio 8 | try { 9 | let ctx = new (window.AudioContext||window.webkitAudioContext)({ 10 | sampleRate: 8000 11 | }); 12 | 13 | capabilities.sampleRate = (ctx.sampleRate === 8000); 14 | ctx.close().then(() => console.log("Closed capability detection audio context.")); 15 | } catch(e) { 16 | console.log("WebAudio sample rate capability detection failed. Assuming fallback."); 17 | } 18 | 19 | // Evaluate streaming 20 | try { 21 | let b = new Uint8Array(2**16); 22 | 23 | let blob = new Blob([b], {type:"application/octet-stream"}); 24 | let u = URL.createObjectURL(blob); 25 | let resp = await fetch(u); 26 | let body = await resp.body; 27 | const reader = body.getReader(); 28 | 29 | while (true) { 30 | let d = await reader.read(); 31 | if (d.done) { 32 | break; 33 | } 34 | } 35 | capabilities.streaming = true; 36 | } catch(e) { 37 | console.log("Streaming capability detection failed. Assuming fallback."); 38 | } 39 | 40 | // Check for Chrome 89 41 | // https://stackoverflow.com/a/4900484 42 | // https://github.com/rphsoftware/revolving-door/issues/10 43 | // To Rph: Remove this chunk of code if you manage to implement a proper fix before the heat death of the universe. 44 | var raw = navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./); 45 | var chromeVersion = (raw ? parseInt(raw[2], 10) : false); 46 | 47 | if(chromeVersion !== false && chromeVersion >= 89) { 48 | //Disable native resampling 49 | capabilities.sampleRate = false; 50 | 51 | console.log('Chrome 89 or newer detected, using audio code workarounds.'); 52 | } 53 | 54 | return capabilities; 55 | } 56 | -------------------------------------------------------------------------------- /src/webAudioUnlock.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = function(ac) { 3 | return new Promise(async function(resolve) { 4 | let alreadyremoved = false; 5 | let unlockWrapper = document.createElement("div"); 6 | unlockWrapper.style = `background: #888a; z-index: 88888; position: fixed; top: 0; bottom: 0; left: 0; right: 0; display: flex; align-items: center; justify-content: center;` 7 | let unlockPrompt = document.createElement("div"); 8 | unlockPrompt.style = `display: flex; align-items: center; justify-content: center; flex-direction: column`; 9 | unlockPrompt.innerHTML = ` 10 | 13 | 14 |

Tap or click anywhere to enable audio.

`; 15 | 16 | unlockWrapper.appendChild(unlockPrompt); 17 | 18 | setTimeout(function() { 19 | if (!alreadyremoved) 20 | document.body.appendChild(unlockWrapper); 21 | }, 200); 22 | 23 | 24 | ac.onstatechange = function() { 25 | if (ac.state == "running") { 26 | resolve(); 27 | unlockWrapper.remove(); 28 | alreadyremoved = true; 29 | } 30 | } 31 | 32 | try { 33 | ac.resume(); 34 | } catch(e) { 35 | console.error(e); 36 | } 37 | 38 | unlockWrapper.addEventListener("touchend", async function() { 39 | await ac.resume(); 40 | if (ac.state === "running") { 41 | resolve(); 42 | unlockWrapper.remove(); 43 | alreadyremoved = true; 44 | } 45 | }); 46 | 47 | unlockWrapper.addEventListener("click", async function() { 48 | await ac.resume(); 49 | if (ac.state === "running") { 50 | resolve(); 51 | unlockWrapper.remove(); 52 | alreadyremoved = true; 53 | } 54 | }); 55 | 56 | if (ac.state === "running") { 57 | resolve(); 58 | unlockWrapper.remove(); 59 | alreadyremoved = true; 60 | } 61 | }); 62 | } -------------------------------------------------------------------------------- /src/gestureEngine.js: -------------------------------------------------------------------------------- 1 | let currentlyGesturing = false; 2 | let activeArea = ""; 3 | let activeAreaElem = null; 4 | 5 | let operationListeners = new Map(); 6 | let finishedListeners = new Map(); 7 | 8 | function sanitize(x, y) { 9 | let bcr = activeAreaElem.getBoundingClientRect(); 10 | 11 | let xx = x - bcr.x; 12 | let yy = y - bcr.y; 13 | 14 | if (xx < 0) xx = 0; 15 | if (yy < 0) yy = 0; 16 | if (xx > bcr.width) xx = bcr.width; 17 | if (yy > bcr.height) yy = bcr.height; 18 | 19 | return [xx, yy]; 20 | } 21 | 22 | function fireOp(e, x, y) { 23 | if (operationListeners.has(e)) { 24 | for (let i = 0; i < operationListeners.get(e).length; i++) { 25 | operationListeners.get(e)[i](x, y); 26 | } 27 | } 28 | } 29 | 30 | function fireFin(e, x, y) { 31 | if (finishedListeners.has(e)) { 32 | for (let i = 0; i < finishedListeners.get(e).length; i++) { 33 | finishedListeners.get(e)[i](x, y); 34 | } 35 | } 36 | } 37 | 38 | 39 | module.exports.registerOpEvent = function(element, cb) { 40 | if (operationListeners.has(element)) { 41 | let z = operationListeners.get(element); 42 | z.push(cb); 43 | operationListeners.set(element, z); 44 | } else { 45 | operationListeners.set(element, [cb]); 46 | } 47 | } 48 | 49 | module.exports.registerFinEvent = function(element, cb) { 50 | if (finishedListeners.has(element)) { 51 | let z = finishedListeners.get(element); 52 | z.push(cb); 53 | finishedListeners.set(element, z); 54 | } else { 55 | finishedListeners.set(element, [cb]); 56 | } 57 | } 58 | 59 | 60 | module.exports.runGestureEngine = function() { 61 | document.addEventListener("mousedown", function(e) { 62 | if (e.target.dataset.gestureHitzone) { 63 | currentlyGesturing = true; 64 | activeArea = e.target.dataset.gestureHitzone; 65 | activeAreaElem = e.target; 66 | 67 | let [x, y] = sanitize(e.clientX, e.clientY); 68 | fireOp(activeArea, x, y); 69 | } 70 | }); 71 | 72 | document.addEventListener("mousemove", function(e) { 73 | if (currentlyGesturing) { 74 | let [x, y] = sanitize(e.clientX, e.clientY); 75 | fireOp(activeArea, x, y); 76 | } 77 | }); 78 | 79 | document.addEventListener("mouseup", function(e) { 80 | if (currentlyGesturing) { 81 | let [x, y] = sanitize(e.clientX, e.clientY); 82 | fireFin(activeArea, x, y); 83 | 84 | currentlyGesturing = false; 85 | activeAreaElem = null; 86 | activeArea = ""; 87 | } 88 | }); 89 | } 90 | -------------------------------------------------------------------------------- /player.css: -------------------------------------------------------------------------------- 1 | .guiholder { 2 | position: fixed; 3 | bottom: 0; 4 | right: 0; 5 | width: 350px; 6 | height: 100px; 7 | background: #000; 8 | font-family: sans-serif; 9 | color: white; 10 | 11 | user-select: none; 12 | -webkit-user-select: none; 13 | -webkit-user-drag: none; 14 | -moz-user-select: none; 15 | -ms-user-select: none; 16 | } 17 | 18 | #gui-loading-bar { 19 | position: absolute; 20 | top: 0; 21 | height: 4px; 22 | width: 100%; 23 | background-color: #333; 24 | } 25 | 26 | #gui-inner-loading-bar{ 27 | height: 4px; 28 | width: 30%; 29 | background: blue; 30 | display: none; 31 | } 32 | #gui-loading-bar[data-exists="true"]>#gui-inner-loading-bar { 33 | display: block; 34 | animation: animation 5s ease-in-out infinite; 35 | } 36 | 37 | @keyframes animation { 38 | 0% { 39 | margin-left: 0; 40 | background: hsl(200, 85%, 55%); 41 | } 42 | 50% { 43 | margin-left: 70%; 44 | background: hsl(200, 85%, 85%); 45 | } 46 | 100% { 47 | margin-left: 0; 48 | background: hsl(200, 85%, 55%); 49 | } 50 | } 51 | 52 | .guistate[data-guistate="preload"] { 53 | color: white; 54 | font-size: 24px; 55 | width: 350px; 56 | line-height: 100px; 57 | text-align: center; 58 | } 59 | .guistate[data-guistate="preload"]>h3 { 60 | color: white; 61 | font-size: 24px; 62 | width: 350px; 63 | line-height: 50px; 64 | text-align: center; 65 | } 66 | .guistate[data-guistate="ready"] { 67 | width: 350px; 68 | height: 100px; 69 | display: grid; 70 | grid-template-columns: 8px 16px 32px 8px 254px 8px 16px 8px; 71 | grid-template-rows: 8px 8px 16px 16px 28px 16px 8px; 72 | 73 | grid-template-areas: 74 | "ftop ftop ftop ftop ftop ftop ftop ftop" 75 | "fleft control control asf asf asf volume fright" 76 | "fleft control control nsf time onsf volume fright" 77 | "fleft control control nsf seek onsf volume fright" 78 | "fleft free free free free free volume fright" 79 | "fleft loop loop loop loop lofree volume fright" 80 | "fbot fbot fbot fbot fbot fbot fbot fbot"; 81 | } 82 | 83 | #pl-pause-play {grid-area: control;} 84 | #pl-volume {grid-area: volume;} 85 | #pl-timing {grid-area: time;} 86 | #pl-seek {grid-area: seek;} 87 | #pl-loop {grid-area:loop;} 88 | 89 | #pl-pause-play > svg > path { 90 | fill: #fff; 91 | transition: fill 0.2s ease-in-out; 92 | } 93 | #pl-pause-play:hover > svg > path { 94 | fill: #ddd; 95 | } 96 | #pl-loop { 97 | display: grid; 98 | grid-template-columns: 16px auto 109px 109px; 99 | grid-template-areas: "input text feedback credits"; 100 | } 101 | #pl-loop > .pl-loop-text { 102 | font-size: 12px; 103 | display: flex; 104 | align-items: center; 105 | justify-content: center; 106 | color: white; 107 | line-height: 16px; 108 | margin: 0; 109 | padding: 0; 110 | cursor: pointer; 111 | text-decoration: none; 112 | } 113 | #pl-loop > a.pl-loop-text:hover { 114 | color: orange; 115 | } 116 | #pl-timing { 117 | display: flex; 118 | align-items: center; 119 | justify-content: space-between; 120 | } 121 | .error { 122 | text-align: center; 123 | display: flex; 124 | align-items: Center; 125 | justify-content: center; 126 | flex-direction: column; 127 | height: 100px; 128 | font-size: 14px; 129 | } 130 | .error > h3 { 131 | margin: 0; 132 | } 133 | -------------------------------------------------------------------------------- /src/resampler.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | //JavaScript Audio Resampler 3 | //Copyright (C) 2011-2015 Grant Galitz 4 | //Released to Public Domain 5 | function Resampler(fromSampleRate, toSampleRate, channels, inputBuffer) { 6 | //Input Sample Rate: 7 | this.fromSampleRate = +fromSampleRate; 8 | //Output Sample Rate: 9 | this.toSampleRate = +toSampleRate; 10 | //Number of channels: 11 | this.channels = channels | 0; 12 | //Type checking the input buffer: 13 | if (typeof inputBuffer != "object") { 14 | throw(new Error("inputBuffer is not an object.")); 15 | } 16 | if (!(inputBuffer instanceof Array) && !(inputBuffer instanceof Float32Array) && !(inputBuffer instanceof Float64Array)) { 17 | throw(new Error("inputBuffer is not an array or a float32 or a float64 array.")); 18 | } 19 | this.inputBuffer = inputBuffer; 20 | //Initialize the resampler: 21 | this.initialize(); 22 | } 23 | Resampler.prototype.initialize = function () { 24 | //Perform some checks: 25 | if (this.fromSampleRate > 0 && this.toSampleRate > 0 && this.channels > 0) { 26 | if (this.fromSampleRate == this.toSampleRate) { 27 | //Setup a resampler bypass: 28 | this.resampler = this.bypassResampler; //Resampler just returns what was passed through. 29 | this.ratioWeight = 1; 30 | this.outputBuffer = this.inputBuffer; 31 | } 32 | else { 33 | this.ratioWeight = this.fromSampleRate / this.toSampleRate; 34 | if (this.fromSampleRate < this.toSampleRate) { 35 | /* 36 | Use generic linear interpolation if upsampling, 37 | as linear interpolation produces a gradient that we want 38 | and works fine with two input sample points per output in this case. 39 | */ 40 | this.compileLinearInterpolationFunction(); 41 | this.lastWeight = 1; 42 | } 43 | else { 44 | /* 45 | Custom resampler I wrote that doesn't skip samples 46 | like standard linear interpolation in high downsampling. 47 | This is more accurate than linear interpolation on downsampling. 48 | */ 49 | this.compileMultiTapFunction(); 50 | this.tailExists = false; 51 | this.lastWeight = 0; 52 | } 53 | this.initializeBuffers(); 54 | } 55 | } 56 | else { 57 | throw(new Error("Invalid settings specified for the resampler.")); 58 | } 59 | } 60 | Resampler.prototype.compileLinearInterpolationFunction = function () { 61 | var toCompile = "var outputOffset = 0;\ 62 | if (bufferLength > 0) {\ 63 | var buffer = this.inputBuffer;\ 64 | var weight = this.lastWeight;\ 65 | var firstWeight = 0;\ 66 | var secondWeight = 0;\ 67 | var sourceOffset = 0;\ 68 | var outputOffset = 0;\ 69 | var outputBuffer = this.outputBuffer;\ 70 | for (; weight < 1; weight += " + this.ratioWeight + ") {\ 71 | secondWeight = weight % 1;\ 72 | firstWeight = 1 - secondWeight;"; 73 | for (var channel = 0; channel < this.channels; ++channel) { 74 | toCompile += "outputBuffer[outputOffset++] = (this.lastOutput[" + channel + "] * firstWeight) + (buffer[" + channel + "] * secondWeight);"; 75 | } 76 | toCompile += "}\ 77 | weight -= 1;\ 78 | for (bufferLength -= " + this.channels + ", sourceOffset = Math.floor(weight) * " + this.channels + "; sourceOffset < bufferLength;) {\ 79 | secondWeight = weight % 1;\ 80 | firstWeight = 1 - secondWeight;"; 81 | for (var channel = 0; channel < this.channels; ++channel) { 82 | toCompile += "outputBuffer[outputOffset++] = (buffer[sourceOffset" + ((channel > 0) ? (" + " + channel) : "") + "] * firstWeight) + (buffer[sourceOffset + " + (this.channels + channel) + "] * secondWeight);"; 83 | } 84 | toCompile += "weight += " + this.ratioWeight + ";\ 85 | sourceOffset = Math.floor(weight) * " + this.channels + ";\ 86 | }"; 87 | for (var channel = 0; channel < this.channels; ++channel) { 88 | toCompile += "this.lastOutput[" + channel + "] = buffer[sourceOffset++];"; 89 | } 90 | toCompile += "this.lastWeight = weight % 1;\ 91 | }\ 92 | return outputOffset;"; 93 | this.resampler = Function("bufferLength", toCompile); 94 | } 95 | Resampler.prototype.compileMultiTapFunction = function () { 96 | var toCompile = "var outputOffset = 0;\ 97 | if (bufferLength > 0) {\ 98 | var buffer = this.inputBuffer;\ 99 | var weight = 0;"; 100 | for (var channel = 0; channel < this.channels; ++channel) { 101 | toCompile += "var output" + channel + " = 0;" 102 | } 103 | toCompile += "var actualPosition = 0;\ 104 | var amountToNext = 0;\ 105 | var alreadyProcessedTail = !this.tailExists;\ 106 | this.tailExists = false;\ 107 | var outputBuffer = this.outputBuffer;\ 108 | var currentPosition = 0;\ 109 | do {\ 110 | if (alreadyProcessedTail) {\ 111 | weight = " + this.ratioWeight + ";"; 112 | for (channel = 0; channel < this.channels; ++channel) { 113 | toCompile += "output" + channel + " = 0;" 114 | } 115 | toCompile += "}\ 116 | else {\ 117 | weight = this.lastWeight;"; 118 | for (channel = 0; channel < this.channels; ++channel) { 119 | toCompile += "output" + channel + " = this.lastOutput[" + channel + "];" 120 | } 121 | toCompile += "alreadyProcessedTail = true;\ 122 | }\ 123 | while (weight > 0 && actualPosition < bufferLength) {\ 124 | amountToNext = 1 + actualPosition - currentPosition;\ 125 | if (weight >= amountToNext) {"; 126 | for (channel = 0; channel < this.channels; ++channel) { 127 | toCompile += "output" + channel + " += buffer[actualPosition++] * amountToNext;" 128 | } 129 | toCompile += "currentPosition = actualPosition;\ 130 | weight -= amountToNext;\ 131 | }\ 132 | else {"; 133 | for (channel = 0; channel < this.channels; ++channel) { 134 | toCompile += "output" + channel + " += buffer[actualPosition" + ((channel > 0) ? (" + " + channel) : "") + "] * weight;" 135 | } 136 | toCompile += "currentPosition += weight;\ 137 | weight = 0;\ 138 | break;\ 139 | }\ 140 | }\ 141 | if (weight <= 0) {"; 142 | for (channel = 0; channel < this.channels; ++channel) { 143 | toCompile += "outputBuffer[outputOffset++] = output" + channel + " / " + this.ratioWeight + ";" 144 | } 145 | toCompile += "}\ 146 | else {\ 147 | this.lastWeight = weight;"; 148 | for (channel = 0; channel < this.channels; ++channel) { 149 | toCompile += "this.lastOutput[" + channel + "] = output" + channel + ";" 150 | } 151 | toCompile += "this.tailExists = true;\ 152 | break;\ 153 | }\ 154 | } while (actualPosition < bufferLength);\ 155 | }\ 156 | return outputOffset;"; 157 | this.resampler = Function("bufferLength", toCompile); 158 | } 159 | Resampler.prototype.bypassResampler = function (upTo) { 160 | return upTo; 161 | } 162 | Resampler.prototype.initializeBuffers = function () { 163 | //Initialize the internal buffer: 164 | var outputBufferSize = (Math.ceil(this.inputBuffer.length * this.toSampleRate / this.fromSampleRate / this.channels * 1.000000476837158203125) * this.channels) + this.channels; 165 | try { 166 | this.outputBuffer = new Float32Array(outputBufferSize); 167 | this.lastOutput = new Float32Array(this.channels); 168 | } 169 | catch (error) { 170 | this.outputBuffer = []; 171 | this.lastOutput = []; 172 | } 173 | } 174 | 175 | export default Resampler; -------------------------------------------------------------------------------- /src/gui.js: -------------------------------------------------------------------------------- 1 | const ge = require('./gestureEngine'); 2 | 3 | let state = { 4 | position: 0, 5 | samples: 1e6, 6 | loaded: 5e5, 7 | volume: 1, 8 | paused: false, 9 | 10 | ready: false, 11 | buffering: false, 12 | sampleRate: 4.8e4, 13 | looping: false, 14 | streamingDied: false 15 | }; 16 | 17 | let overrides = { 18 | volume: null, 19 | position: null 20 | } 21 | 22 | let api = {}; 23 | 24 | let volumeCtx = null; 25 | let barCtx = null; 26 | let guiElement = null; 27 | let lastY = -30; 28 | 29 | function hEvent(a) { 30 | try { a.preventDefault(); } catch(e) {} 31 | if (a.targetTouches.length > 0) { 32 | let box = a.targetTouches[0].target.getBoundingClientRect(); 33 | let pos = (a.targetTouches[0].clientY + a.targetTouches[0].radiusY) - box.top; 34 | if (pos < 5) pos = 0; 35 | if (pos > 80) pos = 84; 36 | 37 | let volume = 1 - (pos / 84); 38 | overrides.volume = volume; 39 | if (a.type === "touchend") { 40 | state.volume = overrides.volume; 41 | api.setVolume(overrides.volume); 42 | overrides.volume = null; 43 | localStorage.setItem("volumeoverride", volume); 44 | } 45 | module.exports.guiUpdate(); 46 | } else { 47 | if (a.type === "touchend") { 48 | state.volume = overrides.volume; 49 | api.setVolume(overrides.volume); 50 | overrides.volume = null; 51 | } 52 | } 53 | } 54 | 55 | function hsEvent(a) { 56 | try { a.preventDefault(); } catch(e) {} 57 | if (a.targetTouches.length > 0) { 58 | let box = a.targetTouches[0].target.getBoundingClientRect(); 59 | let pos = (a.targetTouches[0].clientX + a.targetTouches[0].radiusX) - box.left; 60 | 61 | if (pos < 5) pos = 0; 62 | if (pos > 254) pos = 254; 63 | 64 | pos = Math.round(pos); 65 | if (pos === lastY) return; 66 | lastY = pos; 67 | 68 | let posi = state.samples * (pos / 254); 69 | if (posi < state.loaded) { 70 | overrides.position = posi; 71 | } 72 | 73 | if (a.type === "touchend") { 74 | api.seek(overrides.position); 75 | overrides.position = null; 76 | } 77 | 78 | module.exports.guiUpdate(); 79 | } else { 80 | if (a.type === "touchend") { 81 | api.seek(overrides.position); 82 | overrides.position = null; 83 | } 84 | } 85 | } 86 | 87 | function seekOp(x, y) { 88 | let pos = Math.round(x); 89 | let posi = state.samples * (pos / 254); 90 | if (posi < state.loaded) { 91 | overrides.position = posi; 92 | } 93 | module.exports.guiUpdate(); 94 | } 95 | 96 | function seekFin(x, y) { 97 | let pos = Math.round(x); 98 | let posi = state.samples * (pos / 254); 99 | if (posi < state.loaded) { 100 | overrides.position = posi; 101 | } 102 | api.seek(posi); 103 | overrides.position = null; 104 | module.exports.guiUpdate(); 105 | } 106 | 107 | function volOp(x, y) { 108 | y = Math.round(y); 109 | overrides.volume = 1 - (y / 84); 110 | module.exports.guiUpdate(); 111 | } 112 | 113 | function volFin(x, y) { 114 | y = Math.round(y); 115 | let volume = 1 - (y / 84); 116 | overrides.volume = null; 117 | localStorage.setItem("volumeoverride", volume); 118 | api.setVolume(volume); 119 | module.exports.guiUpdate(); 120 | } 121 | 122 | module.exports.updateState = function(newState) { 123 | Object.assign(state, newState); 124 | }; 125 | 126 | module.exports.runGUI = function(a) { 127 | api = a; 128 | // Creating GUI 129 | guiElement = document.createElement("div"); 130 | guiElement.classList.add("guiholder"); 131 | guiElement.innerHTML = ` 132 | 137 |
138 |
139 |
140 |
141 |

Loading song...

142 |
143 |
144 |
145 | 146 | 147 | 148 | 151 |
152 | 153 |
154 | 0:00 155 | 0:00 156 |
157 | 158 |
159 | 160 | Enable loop 161 | Send feedback 162 | v2 by Rph 163 |
164 |
` 165 | 166 | document.body.appendChild(guiElement); 167 | 168 | volumeCtx = document.querySelector("#pl-volume").getContext("2d"); 169 | barCtx = document.querySelector("#pl-seek").getContext("2d"); 170 | 171 | document.querySelector("#pl-volume").addEventListener("touchstart",hEvent); 172 | document.querySelector("#pl-volume").addEventListener("touchmove", hEvent); 173 | document.querySelector("#pl-volume").addEventListener("touchend", hEvent); 174 | 175 | document.querySelector("#pl-seek").addEventListener("touchstart",hsEvent); 176 | document.querySelector("#pl-seek").addEventListener("touchmove", hsEvent); 177 | document.querySelector("#pl-seek").addEventListener("touchend", hsEvent); 178 | 179 | document.querySelector("#pl-pause-play").addEventListener("click", function() { 180 | api.pause(); 181 | module.exports.guiUpdate(); 182 | }); 183 | 184 | document.querySelector("#pl-loop-box").addEventListener("input", function() { 185 | state.looping = document.querySelector("#pl-loop-box").checked; 186 | api.setLoop(state.looping); 187 | }); 188 | 189 | guiElement.addEventListener("drag", function(e) { e.preventDefault(); }); 190 | 191 | ge.runGestureEngine(); 192 | 193 | ge.registerOpEvent("seek", seekOp); 194 | ge.registerFinEvent("seek", seekFin); 195 | ge.registerOpEvent("volume", volOp); 196 | ge.registerFinEvent("volume", volFin); 197 | }; 198 | 199 | let lastShowLoading = null; 200 | let lastReady = null; 201 | let lastVolume = -1; 202 | let lastPosition = -1; 203 | let lastPaused = null; 204 | let lastLength = -1; 205 | let lastPositionS = -1; 206 | let lastLooping = null; 207 | let lastLoaded = -1; 208 | let lastStreamState = null; 209 | 210 | module.exports.guiUpdate = function() { 211 | if (guiElement) { 212 | if (lastStreamState !== state.streamingDied) { 213 | guiElement.querySelector(".error").style.display = state.streamingDied ? "flex":"none"; 214 | lastStreamState = state.streamingDied; 215 | } 216 | let showLoading = (state.buffering || !state.ready); 217 | if (lastShowLoading !== showLoading) { 218 | guiElement.querySelector("#gui-loading-bar").dataset.exists = showLoading; 219 | 220 | lastShowLoading = showLoading; 221 | } 222 | 223 | if (lastReady !== state.ready) { 224 | guiElement.querySelector(".guistate[data-guistate=\"preload\"]").style.display = state.ready ? "none" : "block"; 225 | guiElement.querySelector(".guistate[data-guistate=\"ready\"]").style.display = !state.ready ? "none" : "grid"; 226 | lastReady = state.ready; 227 | } 228 | 229 | if (!state.ready) return; 230 | 231 | let vol = Math.round(84 - (84 * state.volume)); 232 | if (overrides.volume !== null) { 233 | vol = Math.round(84 - (84 * overrides.volume)); 234 | } 235 | if (vol !== lastVolume) { 236 | volumeCtx.fillStyle = "#444"; 237 | volumeCtx.fillRect(0, 0, 16, 84); 238 | 239 | volumeCtx.fillStyle = "hsl(200, 85%, 55%)"; 240 | volumeCtx.fillRect(0, vol, 16, 84); 241 | 242 | lastVolume = vol; 243 | } 244 | 245 | let pos = Math.ceil(((state.position / state.samples) * 254)); 246 | if (overrides.position !== null) { 247 | pos = Math.ceil(((overrides.position / state.samples) * 254)); 248 | } 249 | let loaded = Math.ceil(((state.loaded / state.samples) * 254)); 250 | if ((pos !== lastPosition) || (loaded !== lastLoaded)) { 251 | barCtx.fillStyle = "#222"; 252 | barCtx.fillRect(0, 0, 254, 16); 253 | 254 | barCtx.fillStyle = "#666"; 255 | barCtx.fillRect(0, 0, Math.min(254, loaded), 16); 256 | 257 | barCtx.fillStyle = "hsl(200, 85%, 55%)"; 258 | barCtx.fillRect(0, 0, Math.min(254, pos), 16); 259 | 260 | lastPosition = pos; 261 | lastLoaded = loaded; 262 | } 263 | 264 | if (lastPaused !== state.paused) { 265 | guiElement.querySelector("#pl-pause").style.display = state.paused ? "none" : "block"; 266 | guiElement.querySelector("#pl-play").style.display = !state.paused ? "none" : "block"; 267 | lastPaused = state.paused; 268 | } 269 | 270 | // Seconds in song 271 | let secondsInSong = Math.floor(state.samples / state.sampleRate); 272 | let playbackSeconds = Math.floor(state.position / state.sampleRate); 273 | if (overrides.position !== null) { 274 | playbackSeconds = Math.floor(overrides.position / state.sampleRate); 275 | } 276 | 277 | if (secondsInSong !== lastLength) { 278 | guiElement.querySelector("#pl-time-end").innerText = `${Math.floor(secondsInSong / 60)}:${(secondsInSong % 60).toString().padStart(2, "0")}`; 279 | lastLength = secondsInSong; 280 | } 281 | 282 | if (playbackSeconds !== lastPositionS) { 283 | guiElement.querySelector("#pl-time-start").innerText = `${Math.floor(playbackSeconds / 60)}:${(playbackSeconds % 60).toString().padStart(2, "0")}`; 284 | lastPositionS = playbackSeconds; 285 | } 286 | 287 | if (lastLooping !== state.looping) { 288 | guiElement.querySelector("#pl-loop-box").checked = state.looping; 289 | lastLooping = state.looping; 290 | } 291 | } 292 | } 293 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | // This script shouldn't do anything without explicit user interaction (Triggering playback) 2 | 3 | const browserCapabilities = require('./browserCapabilities'); 4 | const unlock = require('./webAudioUnlock'); 5 | const libbrstm = require('brstm'); 6 | const { STREAMING_MIN_RESPONSE } = require('./configProvider'); 7 | const copyToChannelPolyfill = require('./copyToChannelPolyfill'); 8 | const gui = require('./gui'); 9 | import resampler from './resampler'; 10 | const sleep = timeout => new Promise(resolve => setTimeout(resolve, timeout)); 11 | const powersOf2 = [256, 512, 1024, 2048, 4096, 8192, 16384, 32768]; 12 | 13 | function partitionedGetSamples(brstm, start, size) { 14 | let samples = []; 15 | let got = 0; 16 | for (let i = 0; i < brstm.metadata.numberChannels; i++) { 17 | samples.push(new Int16Array(size)); 18 | } 19 | 20 | while (got < size) { 21 | let buf = brstm.getSamples(start + got, Math.min(brstm.metadata.samplesPerBlock, (size - got))); 22 | for (let i = 0; i < buf.length; i++) { 23 | samples[i].set(buf[i], got); 24 | } 25 | got += Math.min(brstm.metadata.samplesPerBlock, (size - got)); 26 | } 27 | 28 | return samples; 29 | } 30 | 31 | // Player state variables 32 | let hasInitialized = false; // If we measured browser capabilities yet 33 | let capabilities = null; // Capabilities of our browser 34 | let audioContext = null; // WebAudio Audio context 35 | let scriptNode = null; // WebAudio script node 36 | let gainNode = null; // WebAudio gain node 37 | let fullyLoaded = true; // Set to false if file is still streaming 38 | let loadState = 0; // How many bytes we loaded 39 | let playbackCurrentSample = 0; // Current sample of playback (in the LibBRSTM) 40 | let brstm = null; // Instance of LibBRSTM 41 | let brstmBuffer = null; // Memory view shared with LibBRSTM 42 | let paused = false; 43 | let enableLoop = false; 44 | let streamCancel = false; 45 | let playAudioRunning = false; 46 | 47 | let samplesReady = 0; // How many samples the streamer loaded 48 | let volume = (localStorage.getItem("volumeoverride") || 1); 49 | function guiupd() { gui.updateState({position: playbackCurrentSample, paused, volume, loaded: samplesReady, looping: enableLoop}); } 50 | function getResampledSample(sourceSr, targetSr, sample) { 51 | return Math.ceil((sample / sourceSr) * targetSr); 52 | } 53 | 54 | async function loadSongLegacy(url) { // Old song loading logic 55 | let resp = await fetch(url); 56 | let body = await resp.arrayBuffer(); // Fetch whole song 57 | 58 | brstm = new libbrstm.Brstm(body); // Initialize libBRSTM into global state 59 | 60 | fullyLoaded = true; 61 | loadState = Number.MAX_SAFE_INTEGER; // This is legacy loading logic, we can just assume we downloaded everything 62 | samplesReady = Number.MAX_SAFE_INTEGER; 63 | } 64 | 65 | function awaitMessage(content) { 66 | return new Promise(function(resolve) { 67 | function handler(c) { 68 | if (c.data === content && c.isTrusted) { 69 | window.removeEventListener("message", handler); 70 | resolve(); 71 | } 72 | } 73 | 74 | window.addEventListener("message", handler); 75 | }); 76 | } 77 | 78 | function loadSongStreaming(url) { // New, fancy song loading logic 79 | return new Promise(async (resolve, reject) => { 80 | let resp; 81 | let reader; 82 | try { 83 | resp = await fetch(url); 84 | reader = (await resp.body).getReader(); // Initialize reader 85 | } catch(e) { return reject(e); } 86 | brstmBuffer = new ArrayBuffer(parseInt(resp.headers.get("content-length"))); 87 | let bufferView = new Uint8Array(brstmBuffer); // Create shared memory view 88 | let writeOffset = 0; // How much we read 89 | let resolved = false; // Did we resolve the promise already 90 | let brstmHeaderSize = 0; 91 | samplesReady = 0; 92 | fullyLoaded = false; // We are now streaming 93 | streamCancel = false; 94 | while(true) { 95 | let d; 96 | try { 97 | d = await reader.read(); // Read next chunk 98 | } catch(e) { 99 | if (resolved) { 100 | gui.updateState({streamingDied: true, buffering: false, ready:true}); 101 | await audioContext.close(); 102 | audioContext = null; 103 | } else { 104 | reject(e); 105 | } 106 | return; 107 | } 108 | if (streamCancel) { 109 | await reader.cancel(); 110 | window.postMessage("continueload"); 111 | return; 112 | } 113 | if (!d.done) { // This means we will receive more 114 | bufferView.set(d.value, writeOffset); 115 | writeOffset += d.value.length; 116 | loadState = writeOffset; 117 | 118 | // Read the file's header size from the file before passing the file to the BRSTM reader. 119 | if (brstmHeaderSize == 0 && writeOffset > 0x80) { 120 | // Byte order. 0 = LE, 1 = BE. 121 | let endian = 0; 122 | // Read byte order mark. 0x04 123 | let bom = (bufferView[0x04]*256 + bufferView[0x05]); 124 | if (bom == 0xFEFF) { 125 | endian = 1; 126 | } 127 | 128 | // Read the audio offset. 0x70 129 | if(endian == 1) { 130 | brstmHeaderSize = (bufferView[0x70]*16777216 + bufferView[0x71]*65536 + bufferView[0x72]*256 + bufferView[0x73]); 131 | } else { 132 | brstmHeaderSize = (bufferView[0x70] + bufferView[0x71]*256 + bufferView[0x72]*65536 + bufferView[0x73]*16777216); 133 | } 134 | // If the offset in the file turned out to be 0 for some reason or seems to small, 135 | // then fall back to the default minimum size, though the file is very likely to be invalid in this case. 136 | if(brstmHeaderSize < 0x90) { 137 | brstmHeaderSize = STREAMING_MIN_RESPONSE; 138 | } 139 | } 140 | 141 | if (!resolved && brstmHeaderSize != 0 && writeOffset > brstmHeaderSize) { 142 | // Initialize BRSTM instance and allow player to continue loading 143 | try { 144 | brstm = new libbrstm.Brstm(brstmBuffer); 145 | resolve(); 146 | resolved = true; 147 | } catch(e) { 148 | reject(e); 149 | return; 150 | } 151 | 152 | } 153 | if (resolved) { 154 | samplesReady = Math.floor( 155 | ((loadState - brstmHeaderSize) / brstm.metadata.numberChannels) / brstm.metadata.blockSize 156 | ) * brstm.metadata.samplesPerBlock; 157 | } 158 | } else { 159 | if (!resolved) { 160 | // For some reason we haven't resolved yet despite the file finishing 161 | try { 162 | brstm = new libbrstm.Brstm(brstmBuffer); 163 | resolve(); 164 | resolved = true; 165 | } catch(e) { 166 | reject(e); 167 | return; 168 | } 169 | } 170 | fullyLoaded = true; 171 | samplesReady = Number.MAX_SAFE_INTEGER; // Just in case 172 | console.log("File finished streaming"); 173 | break; 174 | } 175 | } 176 | }); 177 | } 178 | 179 | const internalApi = { 180 | setVolume: function(l) { 181 | volume=l; 182 | guiupd(); 183 | if (gainNode) 184 | gainNode.gain.setValueAtTime(volume, audioContext.currentTime); 185 | }, 186 | seek: function(p) { 187 | playbackCurrentSample = Math.floor(p); 188 | guiupd(); 189 | }, 190 | pause: function() { 191 | paused = !paused; 192 | audioContext[paused ? "suspend" : "resume"](); 193 | guiupd(); 194 | }, 195 | setLoop: function(a) { 196 | enableLoop = a; 197 | guiupd(); 198 | } 199 | } 200 | 201 | async function startPlaying(url) { // Entry point to the 202 | if (!hasInitialized) { // We haven't probed the browser for its capabilities yet 203 | capabilities = await browserCapabilities(); 204 | hasInitialized = true; 205 | gui.runGUI(internalApi); 206 | setInterval(function() { gui.updateState({loaded:samplesReady}); gui.guiUpdate(); }, 100); 207 | } // Now we have! 208 | 209 | if (playAudioRunning) return; 210 | playAudioRunning = true; 211 | if (!fullyLoaded) { 212 | 213 | console.log("Cancelling last stream..."); 214 | streamCancel = true; 215 | await awaitMessage("continueload"); 216 | console.log("Done."); 217 | } 218 | if (audioContext) { // We have a previous audio context, we need to murderize it 219 | await audioContext.close(); 220 | audioContext = null; 221 | } 222 | 223 | playbackCurrentSample = 0; // Set the state for playback 224 | paused = false; // Unpause it 225 | 226 | gui.updateState({ // Populate GUI with initial, yet unknown data 227 | ready: false, 228 | position: 0, 229 | samples: 1e6, 230 | loaded: 0, 231 | volume: volume, 232 | paused: false, 233 | buffering: false, 234 | sampleRate: 44100, 235 | streamingDied: false 236 | }); 237 | try { 238 | await (capabilities.streaming ? loadSongStreaming : loadSongLegacy)(url); // Begin loading based on capabilities 239 | } catch(e) { 240 | gui.updateState({streamingDied:true, ready:true, buffering: false}); 241 | console.error(e); 242 | playAudioRunning = false; 243 | return; 244 | } 245 | // The promise returned by the loading method is either resolved after the download is done (legacy) 246 | // Or after we download enough to begin loading (modern) 247 | 248 | audioContext = new (window.AudioContext || window.webkitAudioContext) // Because Safari is retarded 249 | (capabilities.sampleRate ? {sampleRate: brstm.metadata.sampleRate} : { 250 | }); // Do we support sampling? 251 | // If not, we just let the browser pick 252 | 253 | enableLoop = (brstm.metadata.loopFlag === 1) // Set the loop settings respective to the loop flag in brstm file 254 | 255 | await unlock(audioContext); // Request unlocking of the audio context 256 | 257 | if (capabilities.streaming) { 258 | await sleep(1000); // In streaming sometimes the start is slightly crunchy, this should fix it. 259 | } 260 | 261 | // Create the script node 262 | scriptNode = audioContext.createScriptProcessor(0, 0, 2); 263 | 264 | // Process bufferSize 265 | let bufferSize = scriptNode.bufferSize; 266 | 267 | // If we have to resample, the buffer that we get from the BRSTM will be different size. 268 | bufferSize = capabilities.sampleRate ? bufferSize : getResampledSample( 269 | audioContext.sampleRate, 270 | brstm.metadata.sampleRate, 271 | bufferSize 272 | ); 273 | let loadBufferSize = bufferSize; 274 | 275 | // If we resample, we need to also fetch some extra samples to prevent audio glitches 276 | if (!capabilities.sampleRate) { 277 | loadBufferSize += 20; 278 | } 279 | 280 | gui.updateState({ready: true, samples: brstm.metadata.totalSamples}); 281 | gui.updateState({sampleRate: brstm.metadata.sampleRate}); 282 | playAudioRunning = false; 283 | // Set the audio loop callback (called by the browser every time the internal buffer expires) 284 | scriptNode.onaudioprocess = function(audioProcessingEvent) { 285 | guiupd(); 286 | // Get a handle for the audio buffer 287 | let outputBuffer = audioProcessingEvent.outputBuffer; 288 | if (!outputBuffer.copyToChannel) // On safari (Because it's retarded), we have to polyfill this 289 | outputBuffer.copyToChannel = copyToChannelPolyfill; 290 | 291 | // Not enough samples override 292 | if ((playbackCurrentSample + bufferSize + 1024) > samplesReady) { 293 | // override, return early. 294 | gui.updateState({buffering: true}); 295 | console.log("Buffering...."); 296 | outputBuffer.copyToChannel(new Float32Array(scriptNode.bufferSize).fill(0), 0); 297 | outputBuffer.copyToChannel(new Float32Array(scriptNode.bufferSize).fill(0), 1); 298 | return; 299 | } 300 | gui.updateState({buffering: false}); 301 | if (paused) { // If we are paused, we just bail out and return with just zeros 302 | outputBuffer.copyToChannel(new Float32Array(scriptNode.bufferSize).fill(0), 0); 303 | outputBuffer.copyToChannel(new Float32Array(scriptNode.bufferSize).fill(0), 1); 304 | return; 305 | } 306 | 307 | let samples; // Declare the variable for samples 308 | // This will be filled using the below code for handling looping 309 | if ((playbackCurrentSample + loadBufferSize) < brstm.metadata.totalSamples) { // Standard codepath if no loop 310 | // Populate samples with enough that we can just play it (or resample + play it) without glitches 311 | samples = partitionedGetSamples( 312 | brstm, 313 | playbackCurrentSample, 314 | loadBufferSize 315 | ); 316 | 317 | // We use bufferSize not loadBufferSize because the last 20 samples if we have resampling are inaudible 318 | playbackCurrentSample += bufferSize; 319 | } else { 320 | // We are reaching EOF 321 | // Check if we have looping enabled 322 | if (enableLoop) { 323 | // First, get all the samples to the end of the file 324 | samples = partitionedGetSamples( 325 | brstm, 326 | playbackCurrentSample, 327 | (brstm.metadata.totalSamples - playbackCurrentSample) 328 | ); 329 | 330 | let endSamplesLength = samples[0].length; 331 | 332 | console.log((brstm.metadata.totalSamples - playbackCurrentSample), (loadBufferSize - endSamplesLength)); 333 | 334 | // Get enough samples to fully populate the buffer AFTER loop start point 335 | let postLoopSamples = partitionedGetSamples( 336 | brstm, 337 | brstm.metadata.loopStartSample, 338 | (loadBufferSize - endSamplesLength) 339 | ); 340 | 341 | // For every channel, join the first and second buffers created above 342 | for (let i = 0; i < samples.length; i++) { 343 | let buf = new Int16Array(loadBufferSize).fill(0); 344 | buf.set(samples[i]); 345 | buf.set(postLoopSamples[i], samples[i].length); 346 | samples[i] = buf; 347 | } 348 | 349 | // Set to loopStartPoint + length of second buffer (recalculated to not set extra resampling samples) 350 | playbackCurrentSample = brstm.metadata.loopStartSample + bufferSize - endSamplesLength; 351 | } else { 352 | // No looping 353 | // Get enough samples until EOF 354 | samples = partitionedGetSamples( 355 | brstm, 356 | playbackCurrentSample, 357 | (brstm.metadata.totalSamples - playbackCurrentSample - 1) 358 | ); 359 | 360 | // Fill remaining space in the buffer with 0 361 | for (let i = 0; i < samples.length; i++) { 362 | let buf = new Int16Array(loadBufferSize).fill(0); 363 | buf.set(samples[i]); 364 | samples[i] = buf; 365 | } 366 | 367 | // Tell the player that on the next iteration we are at the start and paused 368 | playbackCurrentSample = 0; 369 | paused = true; 370 | setTimeout(function() { audioContext.suspend() }, 200); 371 | } 372 | } 373 | 374 | // In files with too many channels, we just play the first 2 channels 375 | if (samples.length > 2) { 376 | samples = [samples[0], samples[1]]; 377 | } 378 | 379 | // In mono files, we duplicate the channel because stereo is mandatory 380 | if (samples.length === 1) { 381 | samples = [samples[0], samples[0]]; 382 | } 383 | 384 | // Populate outputs for both channels 385 | for (let i = 0; i < samples.length; i++) { 386 | // WebAudio requires Float32 (-1 to 1), we have Int16 (-32768 to 32767) 387 | let chan = new Float32Array(loadBufferSize); 388 | 389 | // Convert to Float32 390 | for (let sid = 0; sid < loadBufferSize; sid++) { 391 | chan[sid] = samples[i][sid] / 32768; 392 | } 393 | 394 | // If we require resampling 395 | if (!capabilities.sampleRate) { 396 | // Initialize the resampler with the original data we got from BRSTM 397 | let zresampler = new resampler(brstm.metadata.sampleRate, audioContext.sampleRate, 1, chan); 398 | 399 | // Resample all the samples we loaded 400 | zresampler.resampler(loadBufferSize); 401 | 402 | // Copy the output to the channel 403 | chan = zresampler.outputBuffer; 404 | 405 | // Cut off excess samples 406 | if (chan.length > scriptNode.bufferSize) { 407 | chan = chan.slice(0, scriptNode.bufferSize); 408 | } 409 | } 410 | 411 | // At last, write all samples to the output buffer 412 | outputBuffer.copyToChannel(chan, i); 413 | } 414 | } 415 | 416 | // Gain node controls volume 417 | gainNode = audioContext.createGain(); 418 | 419 | // Script node needs to pass through gain so it can be controlled 420 | scriptNode.connect(gainNode); 421 | 422 | // Gain node outputs to the actual speakers 423 | gainNode.connect(audioContext.destination); 424 | 425 | // Set gain node volume to `volumeoverride` for remembering the volume 426 | gainNode.gain.setValueAtTime(volume, audioContext.currentTime); 427 | } 428 | 429 | window.player = { 430 | play: startPlaying 431 | } 432 | -------------------------------------------------------------------------------- /dist/bundle.min.js: -------------------------------------------------------------------------------- 1 | !function(){"use strict";function t(t,e,a){const n=[];for(let i=e;i256*t+e),0)}function i(t,e,a){return t<=e?e:t>=a?a:t}function s(t){return t>=32768?t-65536:t}var o=Object.freeze({__proto__:null,Brstm:class{constructor(i){if(this.rawData=new Uint8Array(i),"RSTM"!==function(n,i,s,o=a){const l=t(n,i,s);return o===e&&l.reverse(),String.fromCharCode(...l)}(this.rawData,0,4))throw new Error("Not a valid BRSTM file");this.endianness=function(n){const i=t(n,4,2);return 255===i[0]&&254===i[1]?e:a}(this.rawData),this._offsetToHead=n(this.rawData,16,4,this.endianness),this._offsetToHeadChunk1=this._offsetToHead+n(this.rawData,this._offsetToHead+12,4,this.endianness)+8,this._offsetToHeadChunk2=this._offsetToHead+n(this.rawData,this._offsetToHead+20,4,this.endianness)+8,this._offsetToHeadChunk3=this._offsetToHead+n(this.rawData,this._offsetToHead+28,4,this.endianness)+8,this._offsetToAdpc=n(this.rawData,24,4,this.endianness),this._offsetToData=n(this.rawData,32,4,this.endianness),this.metadata=this._getMetadata(),this._cachedSamples=null,this._partitionedAdpcChunkData=null,this._cachedChannelInfo=null,this._cachedBlockResults=[]}_getChannelInfo(){if(this._cachedChannelInfo)return this._cachedChannelInfo;const{numberChannels:t}=this.metadata,e=[];for(let a=0;a0&&(l=s(n(i,o,2,this.endianness)),o+=2,r=s(n(i,o,2,this.endianness)),o+=2),u[a].push({yn1:l,yn2:r})}let h=[];for(let t=0;te[t])));return this._partitionedAdpcChunkData=h,h}getAllSamples(){if(this._cachedSamples)return this._cachedSamples;const{numberChannels:t,totalSamples:e,totalBlocks:a,samplesPerBlock:n}=this.metadata,i=[];for(let a=0;a>4:15&l[m++],a>=8&&(a-=16);const n=u>>4<<1;a=1024+((1<<(15&u))*a<<11)+e[i(n,0,15)]*h+e[i(n+1,0,15)]*f>>11,f=h,h=i(a,-32768,32767),p.push(h)}tc[i].length?c[i].set(a.slice(0,e-(t*s-o)),t*s-o):c[i].set(a,t*s-o)}else for(let e=0;ea.width&&(n=a.width),i>a.height&&(i=a.height),[n,i]}function g(t,e,a){if(d.has(t))for(let n=0;n0){let s=i.targetTouches[0].target.getBoundingClientRect(),o=i.targetTouches[0].clientY+i.targetTouches[0].radiusY-s.top;o<5&&(o=0),o>80&&(o=84);let l=1-o/84;a.volume=l,"touchend"===i.type&&(e.volume=a.volume,n.setVolume(a.volume),a.volume=null,localStorage.setItem("volumeoverride",l)),t.exports.guiUpdate()}else"touchend"===i.type&&(e.volume=a.volume,n.setVolume(a.volume),a.volume=null)}function u(i){try{i.preventDefault()}catch(t){}if(i.targetTouches.length>0){let s=i.targetTouches[0].target.getBoundingClientRect(),o=i.targetTouches[0].clientX+i.targetTouches[0].radiusX-s.left;if(o<5&&(o=0),o>254&&(o=254),o=Math.round(o),o===l)return;l=o;let r=e.samples*(o/254);r