Skip to content

Commit

Permalink
Refactor cache to use the same decoded audio data
Browse files Browse the repository at this point in the history
  • Loading branch information
ctoth committed May 23, 2024
1 parent 4345f53 commit 1314942
Show file tree
Hide file tree
Showing 2 changed files with 77 additions and 46 deletions.
119 changes: 75 additions & 44 deletions src/cache.ts
Original file line number Diff line number Diff line change
@@ -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<string, Promise<AudioBuffer>>();
private static decodedBuffers = new Map<string, AudioBuffer>();

private static async openCache(): Promise<Cache> {
try {
Expand All @@ -12,63 +19,84 @@ export class CacheManager {
}
}

private static async getAudioBufferFromCache(url: string, cache: Cache, context: AudioContext): Promise<AudioBuffer | null> {
private static async getBufferFromCache(url: string, cache: Cache): Promise<ArrayBuffer | null> {
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<AudioBuffer> {
private static async fetchAndCacheBuffer(url: string, cache: Cache, etag?: string, lastModified?: string): Promise<ArrayBuffer> {
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<AudioBuffer> {
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<AudioBuffer> {
private static async getMetadataFromCache(url: string, cache: Cache): Promise<CacheMetadata | null> {
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<AudioBuffer> {
// 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();
Expand All @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions src/cacophony.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<Group> {
Expand Down

0 comments on commit 1314942

Please sign in to comment.