feat: cache soundfont MP3s in OPFS and add cache controls

This commit is contained in:
Xiaohan-Tian
2026-07-02 14:11:25 -07:00
parent 4f11fe8e25
commit 86ec455dfe
10 changed files with 1040 additions and 55 deletions
@@ -0,0 +1,188 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const {
toneAudioBuffersCtorMock,
existsMock,
getInstrumentObjectUrlsMock,
storeInstrumentMock,
deleteInstrumentMock,
configGetMock,
} = vi.hoisted(() => ({
toneAudioBuffersCtorMock: vi.fn(),
existsMock: vi.fn(),
getInstrumentObjectUrlsMock: vi.fn(),
storeInstrumentMock: vi.fn(),
deleteInstrumentMock: vi.fn(),
configGetMock: vi.fn(),
}));
vi.mock('../../constants/generalMidiConstants', () => ({
FLUIDR3_INSTRUMENT_MAP: {
test_instrument: {
displayName: 'Test Instrument',
midiInstrument: 1,
image: 'test.png',
group: 'TEST',
pitchRange: [60, 61],
},
},
}));
vi.mock('../config/ConfigManager', () => ({
ConfigManager: {
instance: () => ({
getIsInitialized: () => true,
initialize: vi.fn().mockResolvedValue(undefined),
get: configGetMock,
}),
},
}));
vi.mock('../../util/soundfontInstrumentCache', () => ({
SoundfontInstrumentCache: {
exists: existsMock,
getInstrumentObjectUrls: getInstrumentObjectUrlsMock,
storeInstrument: storeInstrumentMock,
deleteInstrument: deleteInstrumentMock,
},
}));
vi.mock('tone', () => ({
ToneAudioBuffers: toneAudioBuffersCtorMock,
}));
import { KGToneBuffersPool } from './KGToneBuffersPool';
describe('KGToneBuffersPool soundfont cache behavior', () => {
beforeEach(() => {
vi.clearAllMocks();
configGetMock.mockReturnValue('https://cdn.example.com/FluidR3_GM/');
existsMock.mockResolvedValue(false);
getInstrumentObjectUrlsMock.mockResolvedValue({
C4: 'blob:cached-c4',
Db4: 'blob:cached-db4',
});
storeInstrumentMock.mockResolvedValue(undefined);
deleteInstrumentMock.mockResolvedValue(undefined);
toneAudioBuffersCtorMock.mockImplementation((options: {
urls: Record<string, string>;
onload: () => void;
onerror?: (error: Error) => void;
}) => {
const buffers = {
loaded: true,
has: (key: string) => key in options.urls,
get: (key: string) => ({ key, duration: 1, loaded: true }),
dispose: vi.fn(),
};
queueMicrotask(() => options.onload());
return buffers;
});
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
if (url.includes('Db4') && url.includes('fail-db4')) {
return new Response(null, { status: 500 });
}
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
}));
vi.stubGlobal('URL', {
createObjectURL: vi.fn((blob: Blob) => `blob:${blob.size}:${Math.random()}`),
revokeObjectURL: vi.fn(),
});
KGToneBuffersPool.instance().dispose();
(KGToneBuffersPool as unknown as { _instance: KGToneBuffersPool | null })._instance = null;
});
it('loads from OPFS cache without refetching remote URLs', async () => {
existsMock.mockResolvedValue(true);
const pool = KGToneBuffersPool.instance();
const buffers = await pool.getToneAudioBuffers('test_instrument');
expect(buffers.loaded).toBe(true);
expect(getInstrumentObjectUrlsMock).toHaveBeenCalledOnce();
expect(fetch).not.toHaveBeenCalled();
});
it('stores a complete remote instrument and reuses the in-memory cache', async () => {
const pool = KGToneBuffersPool.instance();
await pool.getToneAudioBuffers('test_instrument');
await pool.getToneAudioBuffers('test_instrument');
expect(fetch).toHaveBeenCalledTimes(2);
expect(storeInstrumentMock).toHaveBeenCalledOnce();
expect(toneAudioBuffersCtorMock).toHaveBeenCalledOnce();
});
it('does not persist or memoize a partial remote load', async () => {
configGetMock.mockReturnValue('https://fail-db4.example.com/FluidR3_GM/');
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
if (url.includes('Db4')) {
return new Response(null, { status: 500 });
}
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
}));
const pool = KGToneBuffersPool.instance();
const first = await pool.getToneAudioBuffers('test_instrument');
const second = await pool.getToneAudioBuffers('test_instrument');
expect(first.loaded).toBe(true);
expect(second.loaded).toBe(true);
expect(fetch).toHaveBeenCalledTimes(4);
expect(storeInstrumentMock).not.toHaveBeenCalled();
expect(deleteInstrumentMock).toHaveBeenCalled();
});
it('retries remote loading after a previous partial success', async () => {
let requestCount = 0;
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
requestCount += 1;
if (requestCount <= 2 && url.includes('Db4')) {
return new Response(null, { status: 500 });
}
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
}));
const pool = KGToneBuffersPool.instance();
await pool.getToneAudioBuffers('test_instrument');
await pool.getToneAudioBuffers('test_instrument');
expect(fetch).toHaveBeenCalledTimes(4);
expect(storeInstrumentMock).toHaveBeenCalledOnce();
});
it('deduplicates concurrent loads for the same instrument', async () => {
let onloadCount = 0;
toneAudioBuffersCtorMock.mockImplementation((options: {
urls: Record<string, string>;
onload: () => void;
}) => {
const buffers = {
loaded: true,
has: (key: string) => key in options.urls,
get: (key: string) => ({ key, duration: 1, loaded: true }),
dispose: vi.fn(),
};
setTimeout(() => {
onloadCount += 1;
options.onload();
}, 0);
return buffers;
});
const pool = KGToneBuffersPool.instance();
const [first, second] = await Promise.all([
pool.getToneAudioBuffers('test_instrument'),
pool.getToneAudioBuffers('test_instrument'),
]);
expect(first).toBe(second);
expect(fetch).toHaveBeenCalledTimes(2);
expect(toneAudioBuffersCtorMock).toHaveBeenCalledOnce();
expect(onloadCount).toBe(1);
});
});
+190 -54
View File
@@ -1,8 +1,14 @@
import { SAMPLER_CONSTANTS } from '../../constants/coreConstants';
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
import { ConfigManager } from '../config/ConfigManager';
import { SoundfontInstrumentCache } from '../../util/soundfontInstrumentCache';
import * as Tone from 'tone';
interface ToneBufferLoadResult {
buffers: Tone.ToneAudioBuffers;
cacheInMemory: boolean;
}
/**
* KGToneBuffersPool - Singleton class for managing ToneAudioBuffers
* Handles loading and caching of soundfont audio buffers for instruments
@@ -20,6 +26,8 @@ export class KGToneBuffersPool {
// Simple event listeners for load start/end without coupling to UI layer
private loadingListeners: Array<(_evt: { type: 'start' | 'end'; instrument: string }) => void> = [];
private activeBaseUrl: string | null = null;
// Private constructor to prevent direct instantiation
private constructor() {
console.log("KGToneBuffersPool initialized");
@@ -67,6 +75,9 @@ export class KGToneBuffersPool {
* Handles race conditions by ensuring only one loading operation per instrument
*/
public async getToneAudioBuffers(name: string): Promise<Tone.ToneAudioBuffers> {
const baseUrl = await this.getSoundfontBaseUrl();
this.ensureMemoryCacheMatchesBaseUrl(baseUrl);
// Check if already fully loaded and cached
const cachedBuffers = this.bufferMap.get(name);
if (cachedBuffers && cachedBuffers.loaded) {
@@ -82,18 +93,22 @@ export class KGToneBuffersPool {
// Start new loading operation
console.log(`KGToneBuffersPool: Starting new loading operation for ${name}`);
const loadingPromise = this.createToneAudioBuffers(name);
this.loadingPromises.set(name, loadingPromise);
const loadingPromise = this.createToneAudioBuffers(name, baseUrl);
this.loadingPromises.set(name, loadingPromise.then(result => result.buffers));
// Emit start AFTER registering the promise to avoid duplicate start events in races
this.emitLoadingEvent({ type: 'start', instrument: name });
console.log(`[KGToneBuffersPool] start: Active load count: ${this.getActiveLoadCount()}`);
try {
const buffers = await loadingPromise;
const result = await loadingPromise;
const buffers = result.buffers;
// Cache the fully loaded buffers
this.bufferMap.set(name, buffers);
console.log(`KGToneBuffersPool: Cached loaded buffers for ${name}`);
if (result.cacheInMemory) {
this.bufferMap.set(name, buffers);
console.log(`KGToneBuffersPool: Cached loaded buffers for ${name}`);
} else {
console.log(`KGToneBuffersPool: Skipping in-memory cache for ${name} due to partial soundfont load`);
}
// Remove from loading promises since it's complete
this.loadingPromises.delete(name);
@@ -115,43 +130,63 @@ export class KGToneBuffersPool {
/**
* Create ToneAudioBuffers for an instrument
*/
private async createToneAudioBuffers(name: string): Promise<Tone.ToneAudioBuffers> {
const configManager = ConfigManager.instance();
if (!configManager.getIsInitialized()) {
await configManager.initialize();
private async createToneAudioBuffers(name: string, baseUrl: string): Promise<ToneBufferLoadResult> {
const instrumentName = name;
if (!instrumentName) {
throw new Error(`Unknown instrument: ${name}`);
}
return new Promise((resolve, reject) => {
try {
const instrumentName = name;
const keyNames = this.getInstrumentKeyNames(instrumentName);
if (!instrumentName) {
throw new Error(`Unknown instrument: ${name}`);
try {
if (await SoundfontInstrumentCache.exists(instrumentName, keyNames, baseUrl)) {
console.log(`Loading ToneAudioBuffers for ${name} from OPFS cache...`);
const cachedUrls = await SoundfontInstrumentCache.getInstrumentObjectUrls(instrumentName, keyNames, baseUrl);
try {
const buffers = await this.loadToneAudioBuffers(cachedUrls, name);
return { buffers, cacheInMemory: true };
} catch (error) {
console.warn(`Cached soundfont load failed for ${name}, deleting cache and retrying remote download.`, error);
this.revokeObjectUrls(cachedUrls);
await SoundfontInstrumentCache.deleteInstrument(instrumentName);
}
const baseUrl = (ConfigManager.instance().get('general.soundfont.base_url') as string)
|| SAMPLER_CONSTANTS.TONE_SAMPLERS.FLUID.url;
const urls = this.generateKeyUrls(baseUrl, instrumentName);
console.log(`Loading ToneAudioBuffers for ${name} (${instrumentName})...`);
// Create ToneAudioBuffers with onload callback
const buffers = new Tone.ToneAudioBuffers(
urls,
() => {
console.log(`ToneAudioBuffers loaded successfully for ${name}`);
resolve(buffers);
}
);
// Don't cache until loading is complete - this will be handled in getToneAudioBuffers
} catch (error) {
console.error(`Error creating ToneAudioBuffers for ${name}:`, error);
reject(error);
}
});
console.log(`Loading ToneAudioBuffers for ${name} (${instrumentName}) from remote source...`);
const remoteUrls = this.generateKeyUrls(baseUrl, instrumentName);
const fetchResults = await this.fetchRemoteInstrumentBlobs(remoteUrls);
const successfulKeys = Object.keys(fetchResults.successfulBlobs);
if (successfulKeys.length === 0) {
throw new Error(`Failed to load any soundfont samples for ${instrumentName}`);
}
const loadUrls = Object.fromEntries(
successfulKeys.map(key => [key, URL.createObjectURL(fetchResults.successfulBlobs[key])]),
) as Record<string, string>;
const buffers = await this.loadToneAudioBuffers(loadUrls, name);
if (fetchResults.failures.length === 0) {
try {
await SoundfontInstrumentCache.storeInstrument(instrumentName, keyNames, fetchResults.successfulBlobs, baseUrl);
} catch (error) {
console.warn(`Failed to persist soundfont cache for ${instrumentName}:`, error);
}
} else {
console.warn(`Skipping cache finalize for ${instrumentName} because ${fetchResults.failures.length} pitch samples failed to load.`);
await SoundfontInstrumentCache.deleteInstrument(instrumentName);
}
return {
buffers,
cacheInMemory: fetchResults.failures.length === 0,
};
} catch (error) {
console.error(`Error creating ToneAudioBuffers for ${name}:`, error);
throw error;
}
}
/**
@@ -161,21 +196,7 @@ export class KGToneBuffersPool {
private generateKeyUrls(baseUrl: string, instrumentName: string): { [key: string]: string } {
const urls: { [key: string]: string } = {};
// get the range of the instrument.
// TODO: make the sound library name configurable.
const range = FLUIDR3_INSTRUMENT_MAP[instrumentName]?.pitchRange || [21, 108];
// Note names in order (using flats instead of sharps where applicable)
const noteNames = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B'];
// Generate keys from A0 to C8 (MIDI notes 21 to 108)
for (let midiNote = range[0]; midiNote <= range[1]; midiNote++) {
const octave = Math.floor((midiNote - 12) / 12);
const noteIndex = (midiNote - 12) % 12;
const noteName = noteNames[noteIndex];
const keyName = `${noteName}${octave}`;
// Generate URL for this key
for (const keyName of this.getInstrumentKeyNames(instrumentName)) {
urls[keyName] = `${baseUrl}${instrumentName}-mp3/${keyName}.mp3`;
}
@@ -184,6 +205,121 @@ export class KGToneBuffersPool {
return urls;
}
private getInstrumentKeyNames(instrumentName: string): string[] {
const range = FLUIDR3_INSTRUMENT_MAP[instrumentName]?.pitchRange || [21, 108];
const noteNames = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B'];
const keys: string[] = [];
for (let midiNote = range[0]; midiNote <= range[1]; midiNote++) {
const octave = Math.floor((midiNote - 12) / 12);
const noteIndex = (midiNote - 12) % 12;
const noteName = noteNames[noteIndex];
keys.push(`${noteName}${octave}`);
}
return keys;
}
private async fetchRemoteInstrumentBlobs(urls: Record<string, string>): Promise<{
successfulBlobs: Record<string, Blob>;
failures: string[];
}> {
const entries = Object.entries(urls);
const successfulBlobs: Record<string, Blob> = {};
const failures: string[] = [];
await Promise.all(entries.map(async ([key, url]) => {
try {
const response = await this.fetchWithTimeout(url, 10000);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
successfulBlobs[key] = await response.blob();
} catch (error) {
console.warn(`Failed to fetch soundfont sample ${key}:`, error);
failures.push(key);
}
}));
return { successfulBlobs, failures };
}
private async fetchWithTimeout(url: string, timeoutMs: number): Promise<Response> {
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { signal: controller.signal });
} finally {
clearTimeout(timeout);
}
}
private loadToneAudioBuffers(urls: Record<string, string>, name: string): Promise<Tone.ToneAudioBuffers> {
return new Promise((resolve, reject) => {
if (Object.keys(urls).length === 0) {
reject(new Error(`No audio sources were available for ${name}`));
return;
}
let settled = false;
const cleanup = () => this.revokeObjectUrls(urls);
const buffers = new Tone.ToneAudioBuffers({
urls,
onload: () => {
if (settled) return;
settled = true;
cleanup();
console.log(`ToneAudioBuffers loaded successfully for ${name}`);
resolve(buffers);
},
onerror: (error) => {
if (settled) return;
settled = true;
cleanup();
reject(error);
},
});
});
}
private revokeObjectUrls(urls: Record<string, string>): void {
Object.values(urls).forEach(url => {
if (url.startsWith('blob:')) {
URL.revokeObjectURL(url);
}
});
}
private async getSoundfontBaseUrl(): Promise<string> {
const configManager = ConfigManager.instance();
if (!configManager.getIsInitialized()) {
await configManager.initialize();
}
return (configManager.get('general.soundfont.base_url') as string)
|| SAMPLER_CONSTANTS.TONE_SAMPLERS.FLUID.url;
}
private ensureMemoryCacheMatchesBaseUrl(baseUrl: string): void {
if (this.activeBaseUrl === baseUrl) {
return;
}
this.activeBaseUrl = baseUrl;
this.bufferMap.forEach((buffers, name) => {
try {
buffers.dispose();
console.log(`Disposed ToneAudioBuffers for ${name} due to soundfont base URL change`);
} catch (error) {
console.error(`Error disposing ToneAudioBuffers for ${name} during soundfont base URL change:`, error);
}
});
this.bufferMap.clear();
}
/**
* Clear all cached buffers and dispose of resources
*/
@@ -222,4 +358,4 @@ export class KGToneBuffersPool {
await Promise.allSettled(loadPromises);
console.log(`Preloading completed for ${instrumentNames.length} instruments`);
}
}
}