├── .eslintrc ├── .gitignore ├── LICENSE.md ├── README.md ├── audio ├── 1901_bass.mp3 ├── 1901_drumsleft.mp3 ├── 1901_drumsright.mp3 ├── 1901_gtr1.mp3 ├── 1901_gtr2.mp3 ├── 1901_keys.mp3 ├── 1901_leadvox.mp3 ├── 1901_siren.mp3 ├── 1901_synth1.mp3 ├── 1901_synth2.mp3 ├── 1901_triggers.mp3 └── 1901_voxfx.mp3 ├── index.html ├── index.js ├── lib ├── audiostore.js ├── db.js ├── player.js ├── streamcoordinator.js └── streamer.js └── package.json /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": "@starryinternet/starry", 4 | "parserOptions": { 5 | "sourceType": "module" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2023 Kevin Ennis 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. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AudioStore 2 | 3 | `AudioStore` saves blobs to `IndexedDB` and streams small chunks just-in-time. 4 | This has the effect of significantly reducing memory usage of audio-intensive applications, because only a few seconds of audio needs to be loaded into 5 | memory at a time. 6 | 7 | Launch the demo with `npm start` and visit `http://localhost:8000`. 8 | 9 | ### DB 10 | 11 | Generic `IndexedDB` wrapper. Doesn't care about audio, just gets/sets data. 12 | 13 | ### AudioStore 14 | 15 | Audio-aware storage interface. Takes `AudioBuffer` instances, breaks them 16 | into chunks, and saves them to with `db.js`. 17 | 18 | Allows consumers to read `AudioBuffers` of arbitrary length and position 19 | by reading multiple chunks out of `db.js` and stitching them together. 20 | 21 | Essentially, consumers of `AudioStore` don't need to care about *how* things 22 | are stored. They simply ask for an `AudioBuffer` of a given length and offset, 23 | and `AudioStore` will make one on the fly. 24 | 25 | ### Streamer 26 | 27 | Responsible for loading an audio asset via AJAX, saving it to an `AudioStore`, 28 | and then streaming audio back out of the `AudioStore`. 29 | 30 | When the in-memory buffer starts to get low, it requests a new `AudioBuffer` 31 | from the `AudioStore` and schedules playback with sample-level accuracy. 32 | 33 | The idea is that this only holds about ~10s of audio in memory at 34 | any given time. 35 | 36 | ### StreamCoordinator 37 | 38 | Responsible for managing and synchronizing multiple `Streamer` instances. 39 | 40 | Provides a nearly identical API to `Streamer` so that it can be used with 41 | a `Player` instance. 42 | 43 | ### Player 44 | 45 | The UI for a `Streamer` or `StreamCoordinator`. Pretty standard stuff. 46 | -------------------------------------------------------------------------------- /audio/1901_bass.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevincennis/AudioStore/8f179b0d009a2f8820f518cb6fa6467f36d4c9fd/audio/1901_bass.mp3 -------------------------------------------------------------------------------- /audio/1901_drumsleft.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevincennis/AudioStore/8f179b0d009a2f8820f518cb6fa6467f36d4c9fd/audio/1901_drumsleft.mp3 -------------------------------------------------------------------------------- /audio/1901_drumsright.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevincennis/AudioStore/8f179b0d009a2f8820f518cb6fa6467f36d4c9fd/audio/1901_drumsright.mp3 -------------------------------------------------------------------------------- /audio/1901_gtr1.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevincennis/AudioStore/8f179b0d009a2f8820f518cb6fa6467f36d4c9fd/audio/1901_gtr1.mp3 -------------------------------------------------------------------------------- /audio/1901_gtr2.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevincennis/AudioStore/8f179b0d009a2f8820f518cb6fa6467f36d4c9fd/audio/1901_gtr2.mp3 -------------------------------------------------------------------------------- /audio/1901_keys.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevincennis/AudioStore/8f179b0d009a2f8820f518cb6fa6467f36d4c9fd/audio/1901_keys.mp3 -------------------------------------------------------------------------------- /audio/1901_leadvox.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevincennis/AudioStore/8f179b0d009a2f8820f518cb6fa6467f36d4c9fd/audio/1901_leadvox.mp3 -------------------------------------------------------------------------------- /audio/1901_siren.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevincennis/AudioStore/8f179b0d009a2f8820f518cb6fa6467f36d4c9fd/audio/1901_siren.mp3 -------------------------------------------------------------------------------- /audio/1901_synth1.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevincennis/AudioStore/8f179b0d009a2f8820f518cb6fa6467f36d4c9fd/audio/1901_synth1.mp3 -------------------------------------------------------------------------------- /audio/1901_synth2.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevincennis/AudioStore/8f179b0d009a2f8820f518cb6fa6467f36d4c9fd/audio/1901_synth2.mp3 -------------------------------------------------------------------------------- /audio/1901_triggers.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevincennis/AudioStore/8f179b0d009a2f8820f518cb6fa6467f36d4c9fd/audio/1901_triggers.mp3 -------------------------------------------------------------------------------- /audio/1901_voxfx.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevincennis/AudioStore/8f179b0d009a2f8820f518cb6fa6467f36d4c9fd/audio/1901_voxfx.mp3 -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | AudioStore 7 | 8 | 9 | 10 | 120 | 121 | 122 | 123 | Fork me on GitHub 124 | 125 |
126 |

127 |
128 | 129 |
130 |
131 |
132 |
133 |
134 |
135 | 136 |

137 | 138 |
139 | 140 | 141 | 142 | 143 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import AudioStore from './lib/audiostore.js'; 2 | import StreamCoordinator from './lib/streamcoordinator.js'; 3 | import Player from './lib/player.js'; 4 | 5 | const el = document.querySelector('.player'); 6 | const ac = new ( window.AudioContext || window.webkitAudioContext )(); 7 | const store = new AudioStore( ac ); 8 | const logs = document.querySelector('.logs'); 9 | const fdrs = document.querySelector('.faders'); 10 | const info = console.info; 11 | 12 | const urls = [ 13 | 'audio/1901_bass.mp3', 14 | 'audio/1901_drumsleft.mp3', 15 | 'audio/1901_drumsright.mp3', 16 | 'audio/1901_gtr1.mp3', 17 | 'audio/1901_gtr2.mp3', 18 | 'audio/1901_keys.mp3', 19 | 'audio/1901_leadvox.mp3', 20 | 'audio/1901_siren.mp3', 21 | 'audio/1901_synth1.mp3', 22 | 'audio/1901_synth2.mp3', 23 | 'audio/1901_triggers.mp3', 24 | 'audio/1901_voxfx.mp3' 25 | ]; 26 | 27 | const streamer = new StreamCoordinator( urls, store ); 28 | 29 | console.info = str => { 30 | requestAnimationFrame( () => { 31 | const pre = document.createElement('pre'); 32 | pre.textContent = `${ str }\n`; 33 | logs.appendChild( pre ); 34 | logs.scrollTop = Math.pow( 2, 53 ) - 1; 35 | info.call( console, str ); 36 | }); 37 | }; 38 | 39 | // initialize the database 40 | await store.init(); 41 | // load all audio assets 42 | await streamer.load(); 43 | // set up the player 44 | window.player = new Player( el, streamer ); 45 | window.player.seek( 3 ); 46 | 47 | urls.forEach( ( url, i ) => { 48 | const name = url.split('_').pop().split('.').shift(); 49 | const inp = document.createElement('input'); 50 | const lab = document.createElement('label'); 51 | 52 | lab.textContent = name; 53 | 54 | inp.type = 'range'; 55 | inp.min = 0; 56 | inp.max = 100; 57 | inp.value = 50; 58 | 59 | streamer.streamers[ i ].gain.gain.value = 0.5; 60 | 61 | inp.addEventListener( 'input', () => { 62 | streamer.streamers[ i ].gain.gain.value = inp.value / 100; 63 | }); 64 | 65 | lab.appendChild( inp ); 66 | fdrs.appendChild( lab ); 67 | }); 68 | -------------------------------------------------------------------------------- /lib/audiostore.js: -------------------------------------------------------------------------------- 1 | import DB from './db.js'; 2 | 3 | export default class AudioStore { 4 | 5 | /** 6 | * AudioStore constructor 7 | * 8 | * @method constructor 9 | * 10 | * @param {AudioContext} ac – an AudioContext instance 11 | * @param {Object} [opts={}] – optional options object 12 | * @return {AudioStore} 13 | */ 14 | 15 | constructor( ac, opts = {} ) { 16 | Object.assign( this, { ac, db: new DB(), duration: 5 } ); 17 | 18 | // mobile Safari throws up when saving blobs to indexeddb :( 19 | this.blobs = !/iP(ad|hone|pd)/.test( navigator.userAgent ); 20 | 21 | Object.assign( this, opts ); 22 | } 23 | 24 | /** 25 | * Initialize the database 26 | * 27 | * @method init 28 | * 29 | * @return {Promise} – Promise that resolves with an AudioStore 30 | */ 31 | 32 | async init() { 33 | await this.db.init(); 34 | return this; 35 | } 36 | 37 | /** 38 | * get a chunk from the given file name and the given offset 39 | * 40 | * @private getChunk 41 | * 42 | * @param {String} name – file name 43 | * @param {String} seconds – chunk offset in seconds 44 | * @return {Promise} – resolves with a chunk record 45 | */ 46 | 47 | async #getChunk( name, seconds ) { 48 | if ( seconds % this.duration !== 0 ) { 49 | const msg = `${ seconds } is not divisible by ${ this.duration }`; 50 | throw new Error( msg ); 51 | } 52 | 53 | const id = `${ name }-${ seconds }`; 54 | const chunk = await this.db.getRecord( 'chunks', id ); 55 | 56 | return this.#parseChunk( chunk ); 57 | } 58 | 59 | /** 60 | * read a chunk and replace blobs with Float32Arrays 61 | * 62 | * @private parseChunk 63 | * 64 | * @param {Object} chunk – chunk record 65 | * @return {Object} – transformed chunk record 66 | */ 67 | 68 | async #parseChunk( chunk ) { 69 | return new Promise( ( resolve, reject ) => { 70 | if ( !this.blobs ) { 71 | chunk.channels = chunk.channels.map( channel => { 72 | return this.#stringToFloat32Array( channel ); 73 | }); 74 | resolve( chunk ); 75 | } else { 76 | const channels = []; 77 | 78 | let count = 0; 79 | 80 | for ( let i = 0; i < chunk.channels.length; ++i ) { 81 | const reader = new FileReader(); 82 | 83 | reader.onload = function() { 84 | channels[ i ] = new Float32Array( this.result ); 85 | 86 | if ( ++count === chunk.channels.length ) { 87 | chunk.channels = channels; 88 | resolve( chunk ); 89 | } 90 | }; 91 | 92 | reader.onerror = reject; 93 | 94 | reader.readAsArrayBuffer( chunk.channels[ i ] ); 95 | } 96 | 97 | } 98 | }); 99 | } 100 | 101 | /** 102 | * save a metadata object 103 | * 104 | * @private saveMetadata 105 | * 106 | * @param {Object} record – track metadata 107 | * @return {Promise} – resolves with `true` 108 | */ 109 | 110 | async #saveMetadata( record ) { 111 | return this.db.saveRecords( 'metadata', [ record ] ); 112 | } 113 | 114 | /** 115 | * save an array of chunk data 116 | * 117 | * @private saveMetadata 118 | * 119 | * @param {object} chunks – chunk data 120 | * @return {Promise} – resolves with `true` 121 | */ 122 | 123 | async #saveChunks( records ) { 124 | return this.db.saveRecords( 'chunks', records ); 125 | } 126 | 127 | /** 128 | * convert an AudioBuffer to a metadata object 129 | * 130 | * @private audioBufferToMetadata 131 | * 132 | * @param {String} name – track name 133 | * @param {AudioBuffer} ab – AudioBuffer instance 134 | * @return {Object} – metadata object 135 | */ 136 | 137 | #audioBufferToMetadata( name, ab ) { 138 | const channels = ab.numberOfChannels; 139 | const rate = ab.sampleRate; 140 | const duration = ab.duration; 141 | const chunks = Math.ceil( duration / this.duration ); 142 | return { name, channels, rate, duration, chunks }; 143 | } 144 | 145 | /** 146 | * convert an AudioBuffer to an array of chunk objects 147 | * 148 | * @private audioBufferToRecords 149 | * 150 | * @param {String} name – track name 151 | * @param {AudioBuffer} ab – AudioBuffer instance 152 | * @return {Array} – array of chunk objects 153 | */ 154 | 155 | #audioBufferToRecords( name, ab ) { 156 | const channels = ab.numberOfChannels; 157 | const rate = ab.sampleRate; 158 | const chunk = rate * this.duration; 159 | const samples = ab.duration * rate; 160 | const records = []; 161 | const channelData = []; 162 | 163 | for ( let i = 0; i < channels; ++i ) { 164 | channelData.push( ab.getChannelData( i ) ); 165 | } 166 | 167 | for ( let offset = 0; offset < samples; offset += chunk ) { 168 | const length = Math.min( chunk, samples - offset ); 169 | const seconds = offset / ab.sampleRate; 170 | const id = `${ name }-${ seconds }`; 171 | const record = { id, name, rate, seconds, length }; 172 | 173 | record.channels = channelData.map( data => { 174 | // 4 bytes per 32-bit float... 175 | const byteOffset = offset * 4; 176 | const buffer = new Float32Array( data.buffer, byteOffset, length ); 177 | 178 | if ( !this.blobs ) { 179 | return this.#float32ArrayToString( buffer ); 180 | } else { 181 | return new Blob([ buffer ]); 182 | } 183 | }); 184 | 185 | records.push( record ); 186 | } 187 | 188 | return records; 189 | } 190 | 191 | /** 192 | * merge an array of chunk records into an audiobuffer 193 | * 194 | * @private mergeChunks 195 | * 196 | * @param {Array} chunks – array of chunk records 197 | * @param {Object} metadata – metadata record 198 | * @param {Number} start – start offset in samples 199 | * @param {Number} end – end offset in samples 200 | * @return {AudioBuffer} 201 | */ 202 | 203 | #mergeChunks( chunks, metadata, start, end ) { 204 | const merged = []; 205 | const length = chunks.reduce( ( a, b ) => a + b.length, 0 ); 206 | const samples = end - start; 207 | const rate = metadata.rate; 208 | 209 | for ( let i = 0; i < metadata.channels; ++i ) { 210 | merged[ i ] = new Float32Array( length ); 211 | } 212 | 213 | for ( let i = 0, index = 0; i < chunks.length; ++i ) { 214 | merged.forEach( ( channel, j ) => { 215 | merged[ j ].set( chunks[ i ].channels[ j ], index ); 216 | }); 217 | index += chunks[ i ].length; 218 | } 219 | 220 | const channels = merged.map( f32 => f32.subarray( start, end ) ); 221 | const ab = this.ac.createBuffer( channels.length, samples, rate ); 222 | 223 | channels.forEach( ( f32, i ) => ab.getChannelData( i ).set( f32 ) ); 224 | 225 | return ab; 226 | } 227 | 228 | /** 229 | * convert a Float32Array to a utf-16 String 230 | * 231 | * @private float32ArrayToString 232 | * 233 | * @param {Float32Array} f32 – audio data 234 | * @return {String} – encoded audio data 235 | */ 236 | 237 | #float32ArrayToString( f32 ) { 238 | const { byteOffset, byteLength } = f32; 239 | 240 | const i16 = new Uint16Array( f32.buffer, byteOffset, byteLength / 2 ); 241 | 242 | // this is WAY faster when we can use it 243 | if ( 'TextDecoder' in window ) { 244 | const decoder = new TextDecoder('utf-16'); 245 | return decoder.decode( i16 ); 246 | } 247 | 248 | let str = ''; 249 | 250 | // reduce string concatenations by getting values for a bunch of 251 | // character codes at once. can't do 'em all in one shot though, 252 | // because we'll blow out the call stack. 253 | for ( let i = 0, len = i16.byteLength; i < len; i += 10000 ) { 254 | const length = Math.min( i + 10000, len - i ); 255 | str += String.fromCharCode.apply( null, i16.subarray( i, length ) ); 256 | } 257 | 258 | return str; 259 | } 260 | 261 | /** 262 | * convert a utf-16 string to a Float32Array 263 | * 264 | * @private stringToFloat32Array 265 | * 266 | * @param {String} str – encoded audio data 267 | * @return {Float32Array} – decoded audio data 268 | */ 269 | 270 | #stringToFloat32Array( str ) { 271 | const i16 = new Uint16Array( str.length ); 272 | 273 | for ( let i = 0, len = i16.length; i < len; ++i ) { 274 | i16[ i ] = str.charCodeAt( i ); 275 | } 276 | 277 | const f32 = new Float32Array( i16.buffer ); 278 | 279 | return f32; 280 | } 281 | 282 | /** 283 | * get metadata for the given track name 284 | * 285 | * @method getMetadata 286 | * 287 | * @param {String} name – track name 288 | * @return {Object} – metadata record 289 | */ 290 | 291 | async getMetadata( name ) { 292 | return this.db.getRecord( 'metadata', name ); 293 | } 294 | 295 | /** 296 | * save an AudioBuffer to the database in chunks 297 | * 298 | * @method saveAudioBuffer 299 | * 300 | * @param {String} name – track name 301 | * @param {AudioBuffer} ab – AudioBuffer instance 302 | * @return {Promise} – resolves with `true` 303 | */ 304 | 305 | async saveAudioBuffer( name, ab ) { 306 | console.info( `saving audiobuffer ${ name }` ); 307 | 308 | const chunks = this.#audioBufferToRecords( name, ab ); 309 | const metadata = this.#audioBufferToMetadata( name, ab ); 310 | 311 | await this.#saveChunks( chunks ); 312 | await this.#saveMetadata( metadata ); 313 | 314 | console.info( `saved audiobuffer ${ name }` ); 315 | 316 | return metadata; 317 | } 318 | 319 | /** 320 | * get an AudioBuffer for the given track name 321 | * 322 | * this method will automatically stitch together multiple chunks 323 | * if necessary, we well as perform any trimming needed for 324 | * `offset` and `duration`. 325 | * 326 | * @method getAudioBuffer 327 | * 328 | * @param {String} name – track name 329 | * @param {Number} [offset=0] – offset in seconds 330 | * @param {Number} [duration=10] – duration in seconds 331 | * @return {Promise} – resolves with an AudioBuffer 332 | */ 333 | 334 | async getAudioBuffer( name, offset = 0, duration = 10 ) { 335 | const start = offset; 336 | const end = offset + duration; 337 | const log = `getting audiobuffer ${ name } @ ${ start }s-${ end }s`; 338 | 339 | console.info( log ); 340 | 341 | const metadata = await this.getMetadata( name ); 342 | 343 | if ( offset + duration > metadata.duration ) { 344 | const msg = `${ end } is beyond track duration ${ metadata.duration }`; 345 | throw new Error( msg ); 346 | } 347 | 348 | const rate = metadata.rate; 349 | const seconds = Math.floor( offset / this.duration ) * this.duration; 350 | const samples = Math.ceil( duration * rate ); 351 | const promises = []; 352 | 353 | offset -= seconds; 354 | 355 | const first = Math.floor( offset * rate ); 356 | const last = first + samples; 357 | 358 | let sec = seconds; 359 | 360 | while ( sec - offset < seconds + duration ) { 361 | promises.push( this.#getChunk( name, sec ) ); 362 | sec += this.duration; 363 | } 364 | 365 | const chunks = await Promise.all( promises ); 366 | const ab = this.#mergeChunks( chunks, metadata, first, last ); 367 | const msg = `got audiobuffer ${ name } @ ${ start }s-${ end }s`; 368 | 369 | console.info( msg ); 370 | 371 | return ab; 372 | } 373 | 374 | } 375 | -------------------------------------------------------------------------------- /lib/db.js: -------------------------------------------------------------------------------- 1 | export default class DB { 2 | 3 | /** 4 | * DB constructor 5 | * 6 | * @method constructor 7 | * 8 | * @return {DB} 9 | */ 10 | 11 | constructor() { 12 | this.name = 'AudioStore'; 13 | this.version = 1; 14 | } 15 | 16 | /** 17 | * initialize the database 18 | * 19 | * @method init 20 | * 21 | * @return {Promise} – resolves with a DB instance 22 | */ 23 | 24 | init() { 25 | return new Promise( ( resolve, reject ) => { 26 | const req = window.indexedDB.open( this.name, this.version ); 27 | 28 | let exists = true; 29 | 30 | req.onsuccess = ev => { 31 | if ( exists ) { 32 | const log = `database ${ this.name } v${ this.version } exists`; 33 | console.info( log ); 34 | this.db = ev.target.result; 35 | resolve( this ); 36 | } 37 | }; 38 | 39 | req.onupgradeneeded = ev => { 40 | this.db = ev.target.result; 41 | 42 | if ( this.db.version === this.version ) { 43 | exists = false; 44 | this.#createStores( this.db ).then( () => { 45 | const log = `database ${ this.name } v${ this.version } created`; 46 | console.info( log ); 47 | resolve( this ); 48 | }); 49 | } 50 | }; 51 | 52 | req.onerror = reject; 53 | }); 54 | } 55 | 56 | /** 57 | * create database stores 58 | * 59 | * @private createStores 60 | * 61 | * @param {IndexedDB} db – IndexedDB instance 62 | * @return {Promise} – resolves with IndexedDB instance 63 | */ 64 | 65 | #createStores( db ) { 66 | return new Promise( ( resolve, reject ) => { 67 | const chunks = db.createObjectStore( 'chunks', { keyPath: 'id' } ); 68 | const meta = db.createObjectStore( 'metadata', { keyPath: 'name' } ); 69 | 70 | chunks.createIndex( 'id', 'id', { unique: true } ); 71 | meta.createIndex( 'name', 'name', { unique: true } ); 72 | 73 | // these share a common transaction, so no need to bind both 74 | chunks.transaction.oncomplete = () => resolve( db ); 75 | chunks.transaction.onerror = reject; 76 | }); 77 | } 78 | 79 | /** 80 | * get a record from the database 81 | * 82 | * @method getRecord 83 | * 84 | * @param {String} storename – the objectStore name 85 | * @param {String} id – the record's id 86 | * @return {Promise} – resolves with a record 87 | */ 88 | 89 | getRecord( storename, id ) { 90 | return new Promise( ( resolve, reject ) => { 91 | const transaction = this.db.transaction( storename, 'readwrite' ); 92 | const store = transaction.objectStore( storename ); 93 | const request = store.get( id ); 94 | 95 | request.onsuccess = () => resolve( request.result ); 96 | request.onerror = reject; 97 | }); 98 | } 99 | 100 | /** 101 | * save an array of records to the database 102 | * 103 | * @method saveRecords 104 | * 105 | * @param {String} storename – the objectStore name 106 | * @param {array} records – array of records to upsert 107 | * @return {Promise} – resolves with `true` 108 | */ 109 | 110 | saveRecords( storename, records ) { 111 | return new Promise( ( resolve, reject ) => { 112 | const transaction = this.db.transaction( storename, 'readwrite' ); 113 | const store = transaction.objectStore( storename ); 114 | 115 | records.forEach( record => store.put( record ) ); 116 | 117 | transaction.oncomplete = () => resolve( true ); 118 | transaction.onerror = reject; 119 | }); 120 | } 121 | 122 | } 123 | -------------------------------------------------------------------------------- /lib/player.js: -------------------------------------------------------------------------------- 1 | export default class Player { 2 | 3 | /** 4 | * Player constructor 5 | * 6 | * @method constructor 7 | * 8 | * @param {HTMLElement} el – target element 9 | * @param {Streamer} streamer – Streamer instance 10 | * @return {Player} 11 | */ 12 | 13 | constructor( el, streamer ) { 14 | this.el = el; 15 | this.streamer = streamer; 16 | this.button = el.querySelector('.button'); 17 | this.track = el.querySelector('.track'); 18 | this.progress = el.querySelector('.progress'); 19 | this.scrubber = el.querySelector('.scrubber'); 20 | this.message = el.querySelector('.message'); 21 | 22 | this.bindEvents(); 23 | this.draw(); 24 | } 25 | 26 | /** 27 | * bind event handlers 28 | * 29 | * @method bindEvents 30 | * 31 | * @return {Undefined} 32 | */ 33 | 34 | bindEvents() { 35 | this.button.addEventListener( 'click', e => this.toggle( e ) ); 36 | this.scrubber.addEventListener( 'mousedown', e => this.onMouseDown( e ) ); 37 | this.track.addEventListener( 'click', e => this.onClick( e ) ); 38 | window.addEventListener( 'mousemove', e => this.onDrag( e ) ); 39 | window.addEventListener( 'mouseup', e => this.onMouseUp( e ) ); 40 | } 41 | 42 | /** 43 | * begin playback at offset 44 | * 45 | * @method play 46 | * 47 | * @param {Number} position – offset in seconds 48 | * @return {Player} 49 | */ 50 | 51 | play( position ) { 52 | this.pause(); 53 | this.streamer.stream( position ); 54 | this.playing = true; 55 | return this; 56 | } 57 | 58 | /** 59 | * pause playback 60 | * 61 | * @method pause 62 | * 63 | * @return {Player} 64 | */ 65 | 66 | pause() { 67 | this.streamer.stop(); 68 | this.playing = false; 69 | return this; 70 | } 71 | 72 | /** 73 | * set playback offset 74 | * 75 | * @method seek 76 | * 77 | * @param {Number} position – offset in seconds 78 | * @return {Player} 79 | */ 80 | 81 | seek( position ) { 82 | position = Math.min( position, this.streamer.duration - 0.5 ); 83 | this.streamer.seek( position ); 84 | return this; 85 | } 86 | 87 | /** 88 | * get the current playback offset 89 | * 90 | * @method seek 91 | * 92 | * @param {Number} 93 | * @return {Number} – offset in seconds 94 | */ 95 | 96 | updatePosition() { 97 | this.position = this.streamer.currentTime(); 98 | if ( this.streamer.stopped ) { 99 | this.pause(); 100 | } 101 | return this.position; 102 | } 103 | 104 | /** 105 | * toggle between play and pause 106 | * 107 | * @method toggle 108 | * 109 | * @return {Player} 110 | */ 111 | 112 | toggle() { 113 | if ( !this.playing ) { 114 | this.play(); 115 | } else { 116 | this.pause(); 117 | } 118 | return this; 119 | } 120 | 121 | /** 122 | * handle mousedown events for dragging 123 | * 124 | * @method onMouseDown 125 | * 126 | * @param {Event} e – mousedown events 127 | * @return {Undefined} 128 | */ 129 | 130 | onMouseDown( e ) { 131 | this.dragging = true; 132 | this.startX = e.pageX; 133 | this.startLeft = parseInt( this.scrubber.style.left || 0, 10 ); 134 | } 135 | 136 | /** 137 | * handle mousemove events for dragging 138 | * 139 | * @method onDrag 140 | * 141 | * @param {Event} e – mousemove events 142 | * @return {Undefined} 143 | */ 144 | 145 | onDrag( e ) { 146 | if ( !this.dragging ) { 147 | return; 148 | } 149 | const width = this.track.offsetWidth; 150 | const position = this.startLeft + ( e.pageX - this.startX ); 151 | const left = Math.max( Math.min( width, position ), 0 ); 152 | 153 | this.scrubber.style.left = left + 'px'; 154 | } 155 | 156 | /** 157 | * handle mouseup events for dragging 158 | * 159 | * @method onMouseUp 160 | * 161 | * @param {Event} e – mouseup events 162 | * @return {Undefined} 163 | */ 164 | 165 | onMouseUp( e ) { 166 | let isClick = false; 167 | let target = e.target; 168 | 169 | while ( target ) { 170 | isClick = isClick || target === this.track; 171 | target = target.parentElement; 172 | } 173 | 174 | if ( this.dragging && !isClick ) { 175 | const width = this.track.offsetWidth; 176 | const left = parseInt( this.scrubber.style.left || 0, 10 ); 177 | const pct = Math.min( left / width, 1 ); 178 | const time = this.streamer.duration * pct; 179 | this.seek( time ); 180 | this.dragging = false; 181 | return false; 182 | } 183 | } 184 | 185 | /** 186 | * handle click events for seeking 187 | * 188 | * @method onClick 189 | * 190 | * @param {Event} e – click events 191 | * @return {Undefined} 192 | */ 193 | 194 | onClick( e ) { 195 | const width = this.track.offsetWidth; 196 | const offset = this.track.offsetLeft; 197 | const left = e.pageX - offset; 198 | const pct = Math.min( left / width, 1 ); 199 | const time = this.streamer.duration * pct; 200 | 201 | this.seek( time ); 202 | 203 | this.scrubber.style.left = left + 'px'; 204 | 205 | this.dragging = false; 206 | this.moved = false; 207 | } 208 | 209 | /** 210 | * update scrubber and progress bar positions 211 | * 212 | * @method draw 213 | * 214 | * @return {Player} 215 | */ 216 | 217 | draw() { 218 | const progress = ( this.updatePosition() / this.streamer.duration ); 219 | const width = this.track.offsetWidth; 220 | 221 | if ( this.playing ) { 222 | this.button.classList.add('fa-pause'); 223 | this.button.classList.remove('fa-play'); 224 | } else { 225 | this.button.classList.add('fa-play'); 226 | this.button.classList.remove('fa-pause'); 227 | } 228 | 229 | this.progress.style.width = ( progress * width ) + 'px'; 230 | 231 | if ( !this.dragging ) { 232 | this.scrubber.style.left = ( progress * width ) + 'px'; 233 | } 234 | 235 | requestAnimationFrame( () => this.draw() ); 236 | } 237 | 238 | } 239 | -------------------------------------------------------------------------------- /lib/streamcoordinator.js: -------------------------------------------------------------------------------- 1 | import Streamer from './streamer.js'; 2 | 3 | export default class StreamCoordinator { 4 | 5 | /** 6 | * StreamCoordinator constructor 7 | * 8 | * Basically, this sort of *looks* like Streamer in terms of the API, 9 | * but it actually synchronizes *multiple* streamer instances 10 | * 11 | * @method constructor 12 | * 13 | * @param {Array} urls – array of audio asset url 14 | * @param {AudioStore} store – AudioStore instance 15 | * @return {StreamCoordinator} 16 | */ 17 | 18 | constructor( urls, store ) { 19 | this.ac = store.ac; 20 | this.store = store; 21 | this.urls = urls; 22 | 23 | this.streamers = this.urls.map( url => new Streamer( url, store ) ); 24 | 25 | // throwaway audio buffer 26 | this.garbageBuffer = this.ac.createBuffer( 1, 1, 44100 ); 27 | 28 | this.startTime = null; 29 | this.startOffset = null; 30 | 31 | this.stopped = true; 32 | this.ready = false; 33 | } 34 | 35 | /** 36 | * Begin playback at the supplied offset (or resume playback) 37 | * 38 | * @method stream 39 | * 40 | * @param {Number} offset – offset in seconds (defaults to 0 or last time ) 41 | * @return {StreamCoordinator} 42 | */ 43 | 44 | stream( offset ) { 45 | if ( typeof offset !== 'number' ) { 46 | offset = this.startOffset !== null ? this.startOffset : 0; 47 | } 48 | 49 | // mobile browsers require the first AudioBuuferSourceNode#start() call 50 | // to happen in the same call stack as a user interaction. 51 | // 52 | // out Promise-based stuff breaks that, so we try to get ourselves onto 53 | // a good callstack here and play an empty sound if we haven't done 54 | // so already 55 | if ( this.garbageBuffer ) { 56 | const src = this.ac.createBufferSource(); 57 | src.buffer = this.garbageBuffer; 58 | src.start( 0 ); 59 | delete this.garbageBuffer; 60 | } 61 | 62 | const promises = this.streamers.map( streamer => streamer.prime( offset ) ); 63 | 64 | Promise.all( promises ).then( () => { 65 | if ( this.startTime === null ) { 66 | this.startTime = this.ac.currentTime; 67 | } 68 | 69 | this.streamers.forEach( streamer => streamer.stream( offset ) ); 70 | }); 71 | 72 | this.stopped = false; 73 | this.startOffset = offset; 74 | 75 | return this; 76 | } 77 | 78 | /** 79 | * stop all playback 80 | * 81 | * @method stop 82 | * 83 | * @return {StreamCoordinator} 84 | */ 85 | 86 | stop() { 87 | if ( this.stopped ) { 88 | return; 89 | } 90 | 91 | this.streamers.forEach( streamer => streamer.stop() ); 92 | 93 | this.stopped = true; 94 | 95 | const elapsed = this.ac.currentTime - this.startTime; 96 | 97 | this.startTime = null; 98 | this.startOffset += elapsed; 99 | 100 | if ( this.startOffset >= this.duration ) { 101 | this.startOffset = 0; 102 | } 103 | } 104 | 105 | /** 106 | * return the current cursor position in seconds 107 | * 108 | * @method currentTime 109 | * 110 | * @return {Number} – current playback position in seconds 111 | */ 112 | 113 | currentTime() { 114 | if ( this.stopped ) { 115 | return this.startOffset; 116 | } 117 | 118 | const start = this.startTime || this.ac.currentTime; 119 | const offset = this.startOffset || 0; 120 | const elapsed = this.ac.currentTime - start; 121 | 122 | const current = offset + elapsed; 123 | 124 | if ( current >= this.duration ) { 125 | this.stop(); 126 | return 0; 127 | } 128 | 129 | return current; 130 | } 131 | 132 | /** 133 | * set the current cursor position in seconds 134 | * 135 | * @method seek 136 | * @param {Number} offset – offset in seconds 137 | * @return {StreamCoordinator} 138 | */ 139 | 140 | seek( offset ) { 141 | if ( !this.stopped ) { 142 | this.stop(); 143 | this.stream( offset ); 144 | } else { 145 | this.startOffset = offset; 146 | } 147 | } 148 | 149 | /** 150 | * load all audio assets in `this.urls` 151 | * 152 | * @method load 153 | * 154 | * @return {Promise} – resolves with `true` 155 | */ 156 | 157 | async load() { 158 | const promises = this.streamers.map( streamer => streamer.load() ); 159 | 160 | await Promise.all( promises ); 161 | 162 | const durations = this.streamers.map( streamer => streamer.duration ); 163 | 164 | this.duration = Math.max.apply( Math, durations ); 165 | } 166 | 167 | /** 168 | * solo the streamer at the given index (same as the order of `this.urls`) 169 | * 170 | * @method solo 171 | * 172 | * @param {Number} index – streamer index 173 | * @return {StreamCoordinator} 174 | */ 175 | 176 | solo( index ) { 177 | this.streamers.forEach( streamer => streamer.gain.gain.value = 0 ); 178 | this.streamers[ index ].gain.gain.value = 1; 179 | } 180 | 181 | } 182 | -------------------------------------------------------------------------------- /lib/streamer.js: -------------------------------------------------------------------------------- 1 | export default class Streamer { 2 | 3 | /** 4 | * streamer constructor 5 | * 6 | * @method constructor 7 | * 8 | * @param {String} url – audio asset url 9 | * @param {AudioStore} store – AudioStore instance 10 | * @return {Streamer} 11 | */ 12 | 13 | constructor( url, store ) { 14 | this.ac = store.ac; 15 | this.store = store; 16 | this.url = url; 17 | this.name = url.split('/').pop().split('.')[ 0 ]; 18 | this.active = this.ac.createGain(); 19 | this.gain = this.ac.createGain(); 20 | 21 | // throwaway audio buffer 22 | this.garbageBuffer = this.ac.createBuffer( 1, 1, 44100 ); 23 | 24 | this.startTime = null; 25 | this.startOffset = null; 26 | 27 | this.stopped = true; 28 | this.ready = false; 29 | 30 | this.active.connect( this.gain ); 31 | this.gain.connect( this.ac.destination ); 32 | } 33 | 34 | /** 35 | * Preload a chunk so that a subsequent call to `stream()` can 36 | * begin immediately without hitting thr database 37 | * 38 | * @method prime 39 | * 40 | * @param {Number} offset – offset in seconds (defaults to 0 or last time ) 41 | * @return {Promise} – resolves with `this` on completion 42 | */ 43 | 44 | async prime( offset ) { 45 | if ( typeof offset !== 'number' ) { 46 | offset = this.startOffset !== null ? this.startOffset : 0; 47 | } 48 | 49 | if ( !this.ready ) { 50 | throw new Error( `asset ${ this.name } not loaded` ); 51 | } 52 | 53 | if ( offset >= this.duration ) { 54 | throw new Error( `${ offset } is greater than ${ this.duration }` ); 55 | } 56 | 57 | const store = this.store; 58 | const duration = Math.min( 5, this.duration - offset ); 59 | const record = await store.getAudioBuffer( this.name, offset, duration ); 60 | const src = this.ac.createBufferSource(); 61 | 62 | src.buffer = record; 63 | 64 | this.primed = { offset, src }; 65 | 66 | return this; 67 | } 68 | 69 | /** 70 | * Begin playback at the supplied offset (or resume playback) 71 | * 72 | * @method stream 73 | * 74 | * @param {Number} offset – offset in seconds (defaults to 0 or last time ) 75 | * @return {Streamer} 76 | */ 77 | 78 | stream( offset ) { 79 | if ( typeof offset !== 'number' ) { 80 | offset = this.startOffset !== null ? this.startOffset : 0; 81 | } 82 | 83 | if ( !this.ready ) { 84 | throw new Error( `asset ${ this.name } not loaded` ); 85 | } 86 | 87 | if ( this.stopped === false ) { 88 | throw new Error( `stream ${ this.name } is already playing` ); 89 | } 90 | 91 | if ( this.ending ) { 92 | this.ending.onended = () => {}; 93 | this.ending = null; 94 | } 95 | 96 | if ( offset >= this.duration ) { 97 | return this.stop(); 98 | } 99 | 100 | // mobile browsers require the first AudioBuuferSourceNode#start() call 101 | // to happen in the same call stack as a user interaction. 102 | // 103 | // out Promise-based stuff breaks that, so we try to get ourselves onto 104 | // a good callstack here and play an empty sound if we haven't done 105 | // so already 106 | if ( this.garbageBuffer ) { 107 | const src = this.ac.createBufferSource(); 108 | src.buffer = this.garbageBuffer; 109 | src.start( 0 ); 110 | delete this.garbageBuffer; 111 | } 112 | 113 | this.stopped = false; 114 | this.startOffset = offset; 115 | 116 | console.info( `streaming ${ this.name } @ ${ offset }s` ); 117 | 118 | const play = ( src, when, offset, output ) => { 119 | const logtime = ( when - this.ac.currentTime ) * 1000; 120 | const logstr = `playing chunk ${ this.name } @ ${ offset }s`; 121 | 122 | this.logtimer = setTimeout( () => console.info( logstr ), logtime ); 123 | 124 | src.connect( output ); 125 | src.start( when ); 126 | 127 | const dur = src.buffer.duration; 128 | 129 | when += dur; 130 | offset += dur; 131 | 132 | if ( offset >= this.duration ) { 133 | this.ending = src; 134 | src.onended = () => this.stop(); 135 | console.info( `end of file ${ this.name }` ); 136 | return; 137 | } 138 | 139 | const fetchtime = ( when - this.ac.currentTime ) * 1000 - 2000; 140 | 141 | this.fetchtimer = setTimeout( () => { 142 | console.info( `need chunk ${ this.name } @ ${ offset }s` ); 143 | 144 | /* eslint-disable no-use-before-define */ 145 | next( when, offset, output ); 146 | }, fetchtime ); 147 | }; 148 | 149 | const next = ( when = 0, offset = 0, output ) => { 150 | const chunkDuration = Math.min( 5, this.duration - offset ); 151 | this.store.getAudioBuffer( this.name, offset, chunkDuration ) 152 | .then( record => { 153 | if ( this.stopped || output !== this.active ) { 154 | return; 155 | } 156 | 157 | const ab = record; 158 | const src = this.ac.createBufferSource(); 159 | 160 | src.buffer = ab; 161 | 162 | if ( when === 0 ) { 163 | when = this.ac.currentTime; 164 | } 165 | 166 | if ( this.startTime === null ) { 167 | this.startTime = when; 168 | } 169 | 170 | play( src, when, offset, output ); 171 | }) 172 | .catch( err => console.error( err ) ); 173 | }; 174 | 175 | const primed = this.primed; 176 | 177 | delete this.primed; 178 | 179 | if ( primed && primed.offset === offset ) { 180 | return play( primed.src, this.ac.currentTime, offset, this.active ); 181 | } 182 | 183 | next( 0, offset, this.active ); 184 | 185 | return this; 186 | } 187 | 188 | /** 189 | * stop all playback 190 | * 191 | * @method stop 192 | * 193 | * @return {Streamer} 194 | */ 195 | 196 | stop() { 197 | if ( this.stopped || !this.ready ) { 198 | return; 199 | } 200 | 201 | this.stopped = true; 202 | this.active.disconnect(); 203 | this.active = this.ac.createGain(); 204 | this.active.connect( this.gain ); 205 | 206 | const elapsed = this.ac.currentTime - this.startTime; 207 | 208 | this.startTime = null; 209 | this.startOffset += elapsed; 210 | 211 | console.info( `stopping ${ this.name } @ ${ this.startOffset }s` ); 212 | 213 | if ( this.startOffset >= this.duration ) { 214 | this.startOffset = 0; 215 | } 216 | 217 | clearTimeout( this.fetchtimer ); 218 | clearTimeout( this.logtimer ); 219 | 220 | return this; 221 | } 222 | 223 | /** 224 | * return the current cursor position in seconds 225 | * 226 | * @method currentTime 227 | * 228 | * @return {Number} – current playback position in seconds 229 | */ 230 | 231 | currentTime() { 232 | if ( this.stopped ) { 233 | return this.startOffset; 234 | } 235 | 236 | const start = this.startTime || this.ac.currentTime; 237 | const offset = this.startOffset || 0; 238 | const elapsed = this.ac.currentTime - start; 239 | 240 | return offset + elapsed; 241 | } 242 | 243 | /** 244 | * set the current cursor position in seconds 245 | * 246 | * @method seek 247 | * @param {Number} offset – offset in seconds 248 | * @return {Streamer} 249 | */ 250 | 251 | seek( offset ) { 252 | if ( !this.stopped ) { 253 | this.stop(); 254 | this.stream( offset ); 255 | } else { 256 | this.startOffset = offset; 257 | } 258 | } 259 | 260 | /** 261 | * load the audio asset at `this.url` 262 | * 263 | * @method load 264 | * 265 | * @return {Promise} – resolves with `true` 266 | */ 267 | 268 | async load( force = false ) { 269 | 270 | if ( !force ) { 271 | console.info( `checking cache for ${ this.name }` ); 272 | 273 | try { 274 | const { duration } = await this.store.getMetadata( this.name ); 275 | console.info( `cache hit for ${ this.name }` ); 276 | Object.assign( this, { duration, ready: true } ); 277 | return true; 278 | } catch {} 279 | } 280 | 281 | console.info( `fetching ${ this.url }` ); 282 | 283 | return new Promise( ( resolve, reject ) => { 284 | const xhr = new XMLHttpRequest(); 285 | 286 | xhr.open( 'GET', this.url, true ); 287 | xhr.responseType = 'arraybuffer'; 288 | 289 | xhr.onload = () => { 290 | this.ac.decodeAudioData( xhr.response, ab => { 291 | this.store.saveAudioBuffer( this.name, ab ).then( metadata => { 292 | this.duration = metadata.duration; 293 | console.info( `fetched ${ this.url }` ); 294 | this.ready = true; 295 | resolve( true ); 296 | }, reject ); 297 | }, reject ); 298 | }; 299 | 300 | xhr.onerror = reject; 301 | 302 | xhr.send(); 303 | }); 304 | } 305 | 306 | } 307 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "audiostore", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "type": "module", 7 | "scripts": { 8 | "start": "npx static-server -p 8000", 9 | "test": "eslint ." 10 | }, 11 | "author": "", 12 | "license": "MIT", 13 | "devDependencies": { 14 | "@starryinternet/eslint-config-starry": "^10.4.0", 15 | "eslint": "^8.10.0" 16 | } 17 | } 18 | --------------------------------------------------------------------------------