├── .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 |
124 |
125 |
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 |
--------------------------------------------------------------------------------