diff --git a/src/cache.ts b/src/cache.ts index cc99571..21b3e7d 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -1,7 +1,14 @@ -import { AudioContext, AudioBuffer } from './context'; +import type { AudioContext } from './context'; -export class CacheManager { +interface CacheMetadata { + url: string; + etag?: string; + lastModified?: string; +} + +export class AudioCache { private static pendingRequests = new Map>(); + private static decodedBuffers = new Map(); private static async openCache(): Promise { try { @@ -12,63 +19,84 @@ export class CacheManager { } } - private static async getAudioBufferFromCache(url: string, cache: Cache, context: AudioContext): Promise { + private static async getBufferFromCache(url: string, cache: Cache): Promise { try { const response = await cache.match(url); - if (response) { - if (!response.ok) { - throw new Error('Failed to get audio data from cache'); - } - const arrayBuffer = await response.arrayBuffer(); - return context.decodeAudioData(arrayBuffer); + if (response && response.ok) { + return await response.arrayBuffer(); } return null; } catch (error) { - console.error('Failed to get audio data from cache:', error); - throw error; + console.error('Failed to get data from cache:', error); + return null; } } - private static async fetchAndCacheAudioBuffer(url: string, cache: Cache, context: AudioContext, etag?: string, lastModified?: string): Promise { + private static async fetchAndCacheBuffer(url: string, cache: Cache, etag?: string, lastModified?: string): Promise { try { const headers = new Headers(); - if (etag) { - headers.append('If-None-Match', etag); - } - if (lastModified) { - headers.append('If-Modified-Since', lastModified); - } + if (etag) headers.append('If-None-Match', etag); + if (lastModified) headers.append('If-Modified-Since', lastModified); + const fetchResponse = await fetch(url, { headers }); const responseClone = fetchResponse.clone(); + if (fetchResponse.status === 200) { - const newEtag = fetchResponse.headers.get('ETag'); - const newLastModified = fetchResponse.headers.get('Last-Modified'); - const cacheData = { url, etag: newEtag, lastModified: newLastModified }; - cache.put(url, responseClone); - cache.put(url + ':meta', new Response(JSON.stringify(cacheData))); + const newEtag = fetchResponse.headers.get('ETag') || undefined; + const newLastModified = fetchResponse.headers.get('Last-Modified') || undefined; + const cacheData: CacheMetadata = { url, etag: newEtag, lastModified: newLastModified }; + + await cache.put(url, responseClone); + await cache.put(url + ':meta', new Response(JSON.stringify(cacheData), { headers: { 'Content-Type': 'application/json' } })); } else if (fetchResponse.status === 304) { - // The response has not been modified, use the cached version. const cachedResponse = await cache.match(url); if (cachedResponse) { - const arrayBuffer = await cachedResponse.arrayBuffer(); - return context.decodeAudioData(arrayBuffer); + return await cachedResponse.arrayBuffer(); } } - const arrayBuffer = await fetchResponse.arrayBuffer(); - return context.decodeAudioData(arrayBuffer); + + return await fetchResponse.arrayBuffer(); + } catch (error) { + console.error('Failed to fetch and cache data:', error); + throw error; + } + } + + private static async decodeAudioData(context: AudioContext, arrayBuffer: ArrayBuffer): Promise { + try { + return await context.decodeAudioData(arrayBuffer); } catch (error) { - console.error('Failed to fetch and cache audio data:', error); + console.error('Failed to decode audio data:', error); throw error; } } - public static async getAudioBuffer(url: string, context: AudioContext): Promise { + private static async getMetadataFromCache(url: string, cache: Cache): Promise { + try { + const metaResponse = await cache.match(url + ':meta'); + if (metaResponse && metaResponse.ok) { + return await metaResponse.json(); + } + return null; + } catch (error) { + console.error('Failed to get metadata from cache:', error); + return null; + } + } + + public static async getAudioBuffer(context: AudioContext, url: string): Promise { + // Check if the decoded buffer is already available + if (this.decodedBuffers.has(url)) { + return this.decodedBuffers.get(url)!; + } + // handle data: urls if (url.startsWith('data:')) { - // Extract the base64-encoded audio data from the url. const base64Data = url.split(',')[1]; - const buffer = Uint8Array.from(atob(base64Data), c => c.charCodeAt(0)); - return context.decodeAudioData(buffer.buffer); + const buffer = Uint8Array.from(atob(base64Data), c => c.charCodeAt(0)).buffer; + const audioBuffer = await this.decodeAudioData(context, buffer); + this.decodedBuffers.set(url, audioBuffer); + return audioBuffer; } const cache = await this.openCache(); @@ -80,23 +108,26 @@ export class CacheManager { } // Try getting the buffer from cache. - const bufferFromCache = await this.getAudioBufferFromCache(url, cache, context); + const bufferFromCache = await this.getBufferFromCache(url, cache); if (bufferFromCache) { - return bufferFromCache; + const audioBuffer = await this.decodeAudioData(context, bufferFromCache); + this.decodedBuffers.set(url, audioBuffer); + return audioBuffer; } // Check for cached metadata (ETag, Last-Modified) - const metaResponse = await cache.match(url + ':meta'); - let etag; - let lastModified; - if (metaResponse) { - const metaData = await metaResponse.json(); - etag = metaData.etag; - lastModified = metaData.lastModified; - } + const metadata = await this.getMetadataFromCache(url, cache); + const etag = metadata?.etag; + const lastModified = metadata?.lastModified; // If it's not in the cache or needs revalidation, fetch and cache it. - pendingRequest = this.fetchAndCacheAudioBuffer(url, cache, context, etag, lastModified); + pendingRequest = this.fetchAndCacheBuffer(url, cache, etag, lastModified).then(arrayBuffer => { + return this.decodeAudioData(context, arrayBuffer).then(audioBuffer => { + this.decodedBuffers.set(url, audioBuffer); + this.pendingRequests.delete(url); // Cleanup pending request + return audioBuffer; + }); + }); this.pendingRequests.set(url, pendingRequest); return pendingRequest; diff --git a/src/cacophony.ts b/src/cacophony.ts index 4264597..62eb4f2 100644 --- a/src/cacophony.ts +++ b/src/cacophony.ts @@ -1,6 +1,6 @@ import { AudioContext, AudioWorkletNode, IAudioListener, IMediaStreamAudioSourceNode, IPannerNode, IPannerOptions } from 'standardized-audio-context'; import phaseVocoderProcessorWorkletUrl from './bundles/phase-vocoder-bundle.js?url'; -import { CacheManager } from './cache'; +import { AudioCache } from './cache'; import { BiquadFilterNode, GainNode, AudioBuffer } from './context'; import { FilterManager } from './filters'; import { Group } from './group'; @@ -164,7 +164,7 @@ export class Cacophony { audio.crossOrigin = 'anonymous'; return new Sound(url, undefined, this.context, this.globalGainNode, SoundType.HTML, panType); } - return CacheManager.getAudioBuffer(url, this.context).then(buffer => new Sound(url as string, buffer, this.context, this.globalGainNode, soundType, panType)); + return AudioCache.getAudioBuffer(this.context, url).then(buffer => new Sound(url as string, buffer, this.context, this.globalGainNode, soundType, panType)); } async createGroup(sounds: Sound[]): Promise {