feat: cache soundfont MP3s in OPFS and add cache controls
This commit is contained in:
@@ -12,6 +12,14 @@ const { localSeparatorModelCacheMock } = vi.hoisted(() => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const { soundfontInstrumentCacheMock } = vi.hoisted(() => ({
|
||||
soundfontInstrumentCacheMock: {
|
||||
deleteInstrument: vi.fn().mockResolvedValue(undefined),
|
||||
deleteAll: vi.fn().mockResolvedValue(undefined),
|
||||
getCacheSummary: vi.fn().mockResolvedValue({ instrumentCount: 2, instruments: ['acoustic_grand_piano', 'violin'] }),
|
||||
},
|
||||
}));
|
||||
|
||||
const configState = new Map<string, unknown>([
|
||||
['general.language', 'auto'],
|
||||
['general.agent_mode', 'regular'],
|
||||
@@ -90,6 +98,10 @@ vi.mock('../../../util/local-separator/modelCache', () => ({
|
||||
LocalSeparatorModelCache: localSeparatorModelCacheMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../../util/soundfontInstrumentCache', () => ({
|
||||
SoundfontInstrumentCache: soundfontInstrumentCacheMock,
|
||||
}));
|
||||
|
||||
describe('GeneralSettings', () => {
|
||||
const renderSettings = (locale: 'en_us' | 'zh_cn' = 'en_us') => render(
|
||||
<I18nContext.Provider
|
||||
@@ -132,6 +144,10 @@ describe('GeneralSettings', () => {
|
||||
localSeparatorModelCacheMock.delete.mockClear();
|
||||
localSeparatorModelCacheMock.exists.mockClear();
|
||||
localSeparatorModelCacheMock.exists.mockResolvedValue(true);
|
||||
soundfontInstrumentCacheMock.deleteAll.mockClear();
|
||||
soundfontInstrumentCacheMock.deleteInstrument.mockClear();
|
||||
soundfontInstrumentCacheMock.getCacheSummary.mockClear();
|
||||
soundfontInstrumentCacheMock.getCacheSummary.mockResolvedValue({ instrumentCount: 2, instruments: ['acoustic_grand_piano', 'violin'] });
|
||||
});
|
||||
|
||||
it('renders the local context length selector and VRAM hint', async () => {
|
||||
@@ -338,4 +354,50 @@ describe('GeneralSettings', () => {
|
||||
expect(configManagerMock.set).toHaveBeenCalledWith('general.language', 'fr_fr');
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the soundfont cache status and deletes all cached soundfonts', async () => {
|
||||
soundfontInstrumentCacheMock.getCacheSummary
|
||||
.mockResolvedValueOnce({ instrumentCount: 2, instruments: ['acoustic_grand_piano', 'violin'] })
|
||||
.mockResolvedValueOnce({ instrumentCount: 0, instruments: [] });
|
||||
|
||||
renderSettings();
|
||||
|
||||
expect(await screen.findByText('Soundfont Settings')).toBeTruthy();
|
||||
expect(screen.getByText('2 instruments cached in browser storage.')).toBeTruthy();
|
||||
|
||||
const soundfontDeleteButton = screen.getByRole('button', { name: 'Delete Soundfont Cache' });
|
||||
expect(soundfontDeleteButton).not.toBeDisabled();
|
||||
fireEvent.click(soundfontDeleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(soundfontInstrumentCacheMock.deleteAll).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No cached instruments yet.')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes the selected cached soundfont instrument', async () => {
|
||||
soundfontInstrumentCacheMock.getCacheSummary
|
||||
.mockResolvedValueOnce({ instrumentCount: 2, instruments: ['acoustic_grand_piano', 'violin'] })
|
||||
.mockResolvedValueOnce({ instrumentCount: 1, instruments: ['acoustic_grand_piano'] });
|
||||
|
||||
renderSettings();
|
||||
|
||||
const select = await screen.findByLabelText('Cached Instrument');
|
||||
fireEvent.change(select, { target: { value: 'violin' } });
|
||||
|
||||
const deleteSelectedButton = screen.getByRole('button', { name: 'Delete Selected Instrument Cache' });
|
||||
expect(deleteSelectedButton).not.toBeDisabled();
|
||||
fireEvent.click(deleteSelectedButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(soundfontInstrumentCacheMock.deleteInstrument).toHaveBeenCalledWith('violin');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('1 instruments cached in browser storage.')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { ConfigManager } from '../../../core/config/ConfigManager';
|
||||
import { LocalLLMModelManager, type LocalLLMModelState } from '../../../util/localLLMModelManager';
|
||||
import { LocalSeparatorModelCache } from '../../../util/local-separator/modelCache';
|
||||
import { SoundfontInstrumentCache, type SoundfontCacheSummary } from '../../../util/soundfontInstrumentCache';
|
||||
import { useI18n } from '../../../i18n/useI18n';
|
||||
import type { LanguageSetting } from '../../../i18n/types';
|
||||
import {
|
||||
@@ -58,6 +59,11 @@ const GeneralSettings: React.FC = () => {
|
||||
const [kgoneBaseUrl, setKgoneBaseUrl] = useState<string>('');
|
||||
const [kgoneServerManaged, setKgoneServerManaged] = useState<boolean>(false);
|
||||
const [soundfontServerManaged, setSoundfontServerManaged] = useState<boolean>(false);
|
||||
const [soundfontCacheSummary, setSoundfontCacheSummary] = useState<SoundfontCacheSummary>({ instrumentCount: 0, instruments: [] });
|
||||
const [isCheckingSoundfontCache, setIsCheckingSoundfontCache] = useState<boolean>(false);
|
||||
const [isDeletingSoundfontCache, setIsDeletingSoundfontCache] = useState<boolean>(false);
|
||||
const [selectedCachedSoundfontInstrument, setSelectedCachedSoundfontInstrument] = useState<string>('');
|
||||
const [isDeletingCachedSoundfontInstrument, setIsDeletingCachedSoundfontInstrument] = useState<boolean>(false);
|
||||
const [localContextLength, setLocalContextLength] = useState<LocalLLMContextLength>(LOCAL_LLM_DEFAULT_CONTEXT_LENGTH);
|
||||
const [localModelState, setLocalModelState] = useState<LocalLLMModelState>(LocalLLMModelManager.getState());
|
||||
const [localModelUrl, setLocalModelUrl] = useState<string>('');
|
||||
@@ -103,6 +109,35 @@ const GeneralSettings: React.FC = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshSoundfontCacheState = useCallback(async () => {
|
||||
setIsCheckingSoundfontCache(true);
|
||||
try {
|
||||
const currentBaseUrl = ((configManager.get('general.soundfont.base_url') as string) || '').trim();
|
||||
if (!currentBaseUrl) {
|
||||
setSoundfontCacheSummary({ instrumentCount: 0, instruments: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
const summary = await SoundfontInstrumentCache.getCacheSummary(currentBaseUrl);
|
||||
setSoundfontCacheSummary(summary);
|
||||
setSelectedCachedSoundfontInstrument((currentSelection) => {
|
||||
if (summary.instruments.length === 0) {
|
||||
return '';
|
||||
}
|
||||
if (currentSelection && summary.instruments.includes(currentSelection)) {
|
||||
return currentSelection;
|
||||
}
|
||||
return summary.instruments[0];
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to check soundfont cache state:', error);
|
||||
setSoundfontCacheSummary({ instrumentCount: 0, instruments: [] });
|
||||
setSelectedCachedSoundfontInstrument('');
|
||||
} finally {
|
||||
setIsCheckingSoundfontCache(false);
|
||||
}
|
||||
}, [configManager]);
|
||||
|
||||
// Load configuration values on component mount
|
||||
useEffect(() => {
|
||||
const loadConfig = async () => {
|
||||
@@ -150,8 +185,9 @@ const GeneralSettings: React.FC = () => {
|
||||
loadConfig();
|
||||
const unsubscribe = LocalLLMModelManager.subscribe(setLocalModelState);
|
||||
void refreshUvr5ModelCacheState();
|
||||
void refreshSoundfontCacheState();
|
||||
return unsubscribe;
|
||||
}, [configManager, refreshUvr5ModelCacheState]);
|
||||
}, [configManager, refreshSoundfontCacheState, refreshUvr5ModelCacheState]);
|
||||
|
||||
// Debounced save function for text inputs
|
||||
const debouncedSave = useCallback((key: string, value: string) => {
|
||||
@@ -359,6 +395,35 @@ const GeneralSettings: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSoundfontCache = async () => {
|
||||
setIsDeletingSoundfontCache(true);
|
||||
try {
|
||||
await SoundfontInstrumentCache.deleteAll();
|
||||
setSoundfontCacheSummary({ instrumentCount: 0, instruments: [] });
|
||||
} catch (error) {
|
||||
console.error('Failed to delete soundfont cache:', error);
|
||||
} finally {
|
||||
setIsDeletingSoundfontCache(false);
|
||||
await refreshSoundfontCacheState();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCachedSoundfontInstrument = async () => {
|
||||
if (!selectedCachedSoundfontInstrument) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeletingCachedSoundfontInstrument(true);
|
||||
try {
|
||||
await SoundfontInstrumentCache.deleteInstrument(selectedCachedSoundfontInstrument);
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete soundfont cache for ${selectedCachedSoundfontInstrument}:`, error);
|
||||
} finally {
|
||||
setIsDeletingCachedSoundfontInstrument(false);
|
||||
await refreshSoundfontCacheState();
|
||||
}
|
||||
};
|
||||
|
||||
const handleLocalContextLengthChange = async (value: string) => {
|
||||
const parsed = Number(value);
|
||||
const normalized = normalizeLocalLLMContextLength(parsed);
|
||||
@@ -998,6 +1063,75 @@ const GeneralSettings: React.FC = () => {
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-item">
|
||||
<label className="settings-label">
|
||||
{t('settings.general.soundfont.cachedStatus')}
|
||||
</label>
|
||||
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
|
||||
{isCheckingSoundfontCache
|
||||
? t('settings.general.soundfont.cacheChecking')
|
||||
: soundfontCacheSummary.instrumentCount > 0
|
||||
? t('settings.general.soundfont.cacheReady', { count: soundfontCacheSummary.instrumentCount })
|
||||
: t('settings.general.soundfont.cacheEmpty')}
|
||||
</div>
|
||||
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
|
||||
{t('settings.general.soundfont.cacheHelp')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-item">
|
||||
<label className="settings-label" htmlFor="soundfont-cached-instrument-select">
|
||||
{t('settings.general.soundfont.cachedInstrument')}
|
||||
</label>
|
||||
<select
|
||||
id="soundfont-cached-instrument-select"
|
||||
className="settings-select"
|
||||
value={selectedCachedSoundfontInstrument}
|
||||
onChange={(e) => setSelectedCachedSoundfontInstrument(e.target.value)}
|
||||
disabled={isCheckingSoundfontCache || soundfontCacheSummary.instrumentCount === 0 || isDeletingCachedSoundfontInstrument}
|
||||
>
|
||||
{soundfontCacheSummary.instrumentCount === 0 ? (
|
||||
<option value="">{t('settings.general.soundfont.noCachedInstrumentOption')}</option>
|
||||
) : (
|
||||
soundfontCacheSummary.instruments.map((instrumentName) => (
|
||||
<option key={instrumentName} value={instrumentName}>
|
||||
{instrumentName}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
|
||||
{t('settings.general.soundfont.cachedInstrumentHelp')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-item" style={{ marginTop: '12px' }}>
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="settings-btn settings-btn-danger"
|
||||
onClick={() => void handleDeleteCachedSoundfontInstrument()}
|
||||
disabled={
|
||||
isDeletingCachedSoundfontInstrument
|
||||
|| isCheckingSoundfontCache
|
||||
|| !selectedCachedSoundfontInstrument
|
||||
|| soundfontCacheSummary.instrumentCount === 0
|
||||
}
|
||||
>
|
||||
{isDeletingCachedSoundfontInstrument ? t('settings.deleting') : t('settings.general.soundfont.deleteSelectedCache')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="settings-btn settings-btn-danger"
|
||||
onClick={() => void handleDeleteSoundfontCache()}
|
||||
disabled={isDeletingSoundfontCache || isCheckingSoundfontCache || soundfontCacheSummary.instrumentCount === 0}
|
||||
>
|
||||
{isDeletingSoundfontCache ? t('settings.deleting') : t('settings.general.soundfont.deleteCache')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-group">
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
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();
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
private async createToneAudioBuffers(name: string, baseUrl: string): Promise<ToneBufferLoadResult> {
|
||||
const instrumentName = name;
|
||||
|
||||
if (!instrumentName) {
|
||||
throw new Error(`Unknown instrument: ${name}`);
|
||||
}
|
||||
|
||||
const baseUrl = (ConfigManager.instance().get('general.soundfont.base_url') as string)
|
||||
|| SAMPLER_CONSTANTS.TONE_SAMPLERS.FLUID.url;
|
||||
const keyNames = this.getInstrumentKeyNames(instrumentName);
|
||||
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Don't cache until loading is complete - this will be handled in getToneAudioBuffers
|
||||
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);
|
||||
reject(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
|
||||
*/
|
||||
|
||||
@@ -97,6 +97,16 @@ export const enUsMessages: TranslationMessages = {
|
||||
'settings.general.soundfont.managed': 'Soundfont configuration is managed by the server (kgone-server.json). Settings are read-only.',
|
||||
'settings.general.soundfont.baseUrl': 'Base URL',
|
||||
'settings.general.soundfont.baseUrlHelp': 'Changing this URL to an incompatible soundfont source may cause some instruments to sound wrong or not play.',
|
||||
'settings.general.soundfont.cachedStatus': 'Cached Soundfont Status',
|
||||
'settings.general.soundfont.cacheChecking': 'Checking soundfont cache...',
|
||||
'settings.general.soundfont.cacheReady': '{count} instruments cached in browser storage.',
|
||||
'settings.general.soundfont.cacheEmpty': 'No cached instruments yet.',
|
||||
'settings.general.soundfont.cacheHelp': 'Cached instruments are stored in browser OPFS and reused for future instrument loads until you change the base URL or delete the cache.',
|
||||
'settings.general.soundfont.cachedInstrument': 'Cached Instrument',
|
||||
'settings.general.soundfont.cachedInstrumentHelp': 'Choose a cached instrument to delete only that instrument’s local soundfont files.',
|
||||
'settings.general.soundfont.noCachedInstrumentOption': 'No cached instruments',
|
||||
'settings.general.soundfont.deleteSelectedCache': 'Delete Selected Instrument Cache',
|
||||
'settings.general.soundfont.deleteCache': 'Delete Soundfont Cache',
|
||||
'settings.general.kgone.section': 'K.G.One Settings',
|
||||
'settings.general.kgone.managed': 'K.G.One configuration is managed by the server (kgone-server.json). Settings are read-only.',
|
||||
'settings.general.kgone.enabled': 'Enable K.G.One Integration',
|
||||
|
||||
@@ -94,6 +94,16 @@ export const frFrMessages: TranslationMessages = {
|
||||
'settings.general.soundfont.managed': 'La configuration des soundfonts est gérée par le serveur (kgone-server.json). Ces réglages sont en lecture seule.',
|
||||
'settings.general.soundfont.baseUrl': 'URL de base',
|
||||
'settings.general.soundfont.baseUrlHelp': 'Une source de soundfont incompatible peut provoquer des timbres incorrects ou empêcher certains instruments de jouer.',
|
||||
'settings.general.soundfont.cachedStatus': 'État du cache des soundfonts',
|
||||
'settings.general.soundfont.cacheChecking': 'Vérification du cache des soundfonts...',
|
||||
'settings.general.soundfont.cacheReady': '{count} instruments en cache dans le navigateur.',
|
||||
'settings.general.soundfont.cacheEmpty': 'Aucun instrument en cache pour le moment.',
|
||||
'settings.general.soundfont.cacheHelp': 'Les instruments en cache sont stockés dans l’OPFS du navigateur et réutilisés lors des prochains chargements jusqu’à un changement d’URL de base ou une suppression du cache.',
|
||||
'settings.general.soundfont.cachedInstrument': 'Instrument en cache',
|
||||
'settings.general.soundfont.cachedInstrumentHelp': 'Choisissez un instrument en cache pour supprimer uniquement ses fichiers soundfont locaux.',
|
||||
'settings.general.soundfont.noCachedInstrumentOption': 'Aucun instrument en cache',
|
||||
'settings.general.soundfont.deleteSelectedCache': 'Supprimer le cache de l’instrument sélectionné',
|
||||
'settings.general.soundfont.deleteCache': 'Supprimer le cache des soundfonts',
|
||||
'settings.general.kgone.section': 'Réglages K.G.One',
|
||||
'settings.general.kgone.managed': 'La configuration K.G.One est gérée par le serveur (kgone-server.json). Ces réglages sont en lecture seule.',
|
||||
'settings.general.kgone.enabled': 'Activer l\'intégration K.G.One',
|
||||
|
||||
@@ -95,6 +95,16 @@ export const zhCnMessages: TranslationMessages = {
|
||||
'settings.general.soundfont.managed': 'Soundfont 配置由服务器(kgone-server.json)管理,当前设置为只读。',
|
||||
'settings.general.soundfont.baseUrl': '基础 URL',
|
||||
'settings.general.soundfont.baseUrlHelp': '如果改成不兼容的 soundfont 源,某些乐器可能发声错误或无法播放。',
|
||||
'settings.general.soundfont.cachedStatus': 'Soundfont 缓存状态',
|
||||
'settings.general.soundfont.cacheChecking': '正在检查 soundfont 缓存...',
|
||||
'settings.general.soundfont.cacheReady': '浏览器存储中已缓存 {count} 个乐器。',
|
||||
'settings.general.soundfont.cacheEmpty': '当前还没有已缓存的乐器。',
|
||||
'settings.general.soundfont.cacheHelp': '已缓存的乐器会保存在浏览器 OPFS 中,后续加载时会复用;更改基础 URL 或删除缓存后将重新下载。',
|
||||
'settings.general.soundfont.cachedInstrument': '已缓存乐器',
|
||||
'settings.general.soundfont.cachedInstrumentHelp': '选择一个已缓存乐器,只删除该乐器的本地 soundfont 文件。',
|
||||
'settings.general.soundfont.noCachedInstrumentOption': '没有已缓存的乐器',
|
||||
'settings.general.soundfont.deleteSelectedCache': '删除所选乐器缓存',
|
||||
'settings.general.soundfont.deleteCache': '删除 Soundfont 缓存',
|
||||
'settings.general.kgone.section': 'K.G.One 设置',
|
||||
'settings.general.kgone.managed': 'K.G.One 配置由服务器(kgone-server.json)管理,当前设置为只读。',
|
||||
'settings.general.kgone.enabled': '启用 K.G.One 集成',
|
||||
|
||||
@@ -95,6 +95,16 @@ export const zhHkMessages: TranslationMessages = {
|
||||
'settings.general.soundfont.managed': 'Soundfont 設定由伺服器(kgone-server.json)管理,目前設定為唯讀。',
|
||||
'settings.general.soundfont.baseUrl': '基礎 URL',
|
||||
'settings.general.soundfont.baseUrlHelp': '如果改成不兼容的 soundfont 源,某些樂器可能發聲錯誤或無法播放。',
|
||||
'settings.general.soundfont.cachedStatus': 'Soundfont 快取狀態',
|
||||
'settings.general.soundfont.cacheChecking': '正在檢查 soundfont 快取...',
|
||||
'settings.general.soundfont.cacheReady': '瀏覽器儲存空間中已快取 {count} 個樂器。',
|
||||
'settings.general.soundfont.cacheEmpty': '目前還沒有已快取的樂器。',
|
||||
'settings.general.soundfont.cacheHelp': '已快取的樂器會保存在瀏覽器 OPFS 中,後續載入時會重用;更改基礎 URL 或刪除快取後將重新下載。',
|
||||
'settings.general.soundfont.cachedInstrument': '已快取樂器',
|
||||
'settings.general.soundfont.cachedInstrumentHelp': '選擇一個已快取樂器,只刪除該樂器的本地 soundfont 檔案。',
|
||||
'settings.general.soundfont.noCachedInstrumentOption': '沒有已快取的樂器',
|
||||
'settings.general.soundfont.deleteSelectedCache': '刪除所選樂器快取',
|
||||
'settings.general.soundfont.deleteCache': '刪除 Soundfont 快取',
|
||||
'settings.general.kgone.section': 'K.G.One 設定',
|
||||
'settings.general.kgone.managed': 'K.G.One 設定由伺服器(kgone-server.json)管理,目前設定為唯讀。',
|
||||
'settings.general.kgone.enabled': '啟用 K.G.One 集成',
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SoundfontInstrumentCache } from './soundfontInstrumentCache';
|
||||
|
||||
class MockWritableFileStream {
|
||||
private readonly handle: MockFileSystemFileHandle;
|
||||
private chunks: Uint8Array[] = [];
|
||||
|
||||
constructor(handle: MockFileSystemFileHandle) {
|
||||
this.handle = handle;
|
||||
}
|
||||
|
||||
async write(content: Blob | ArrayBuffer | ArrayBufferView | string): Promise<void> {
|
||||
let bytes: Uint8Array;
|
||||
if (typeof content === 'string') {
|
||||
bytes = new TextEncoder().encode(content);
|
||||
} else if (typeof (content as Blob).arrayBuffer === 'function') {
|
||||
bytes = new Uint8Array(await (content as Blob).arrayBuffer());
|
||||
} else if (content instanceof ArrayBuffer) {
|
||||
bytes = new Uint8Array(content);
|
||||
} else {
|
||||
const view = content as ArrayBufferView;
|
||||
bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
|
||||
}
|
||||
this.chunks.push(new Uint8Array(bytes));
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
const total = this.chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
|
||||
const merged = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of this.chunks) {
|
||||
merged.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
this.handle.setContent(merged);
|
||||
}
|
||||
|
||||
async abort(): Promise<void> {
|
||||
this.chunks = [];
|
||||
}
|
||||
}
|
||||
|
||||
class MockFileSystemFileHandle {
|
||||
kind = 'file' as const;
|
||||
private content = new Uint8Array();
|
||||
|
||||
constructor(public readonly name: string) {}
|
||||
|
||||
setContent(content: Uint8Array): void {
|
||||
this.content = content;
|
||||
}
|
||||
|
||||
async getFile(): Promise<File> {
|
||||
return {
|
||||
size: this.content.byteLength,
|
||||
text: async () => new TextDecoder().decode(this.content),
|
||||
} as unknown as File;
|
||||
}
|
||||
|
||||
async createWritable(): Promise<MockWritableFileStream> {
|
||||
return new MockWritableFileStream(this);
|
||||
}
|
||||
}
|
||||
|
||||
class MockFileSystemDirectoryHandle {
|
||||
kind = 'directory' as const;
|
||||
private children = new Map<string, MockFileSystemDirectoryHandle | MockFileSystemFileHandle>();
|
||||
|
||||
constructor(public readonly name: string) {}
|
||||
|
||||
async getDirectoryHandle(name: string, options?: { create?: boolean }): Promise<MockFileSystemDirectoryHandle> {
|
||||
let child = this.children.get(name);
|
||||
if (!child || child.kind !== 'directory') {
|
||||
if (!options?.create) {
|
||||
throw new DOMException(`Directory "${name}" not found`, 'NotFoundError');
|
||||
}
|
||||
child = new MockFileSystemDirectoryHandle(name);
|
||||
this.children.set(name, child);
|
||||
}
|
||||
return child as MockFileSystemDirectoryHandle;
|
||||
}
|
||||
|
||||
async getFileHandle(name: string, options?: { create?: boolean }): Promise<MockFileSystemFileHandle> {
|
||||
let child = this.children.get(name);
|
||||
if (!child || child.kind !== 'file') {
|
||||
if (!options?.create) {
|
||||
throw new DOMException(`File "${name}" not found`, 'NotFoundError');
|
||||
}
|
||||
child = new MockFileSystemFileHandle(name);
|
||||
this.children.set(name, child);
|
||||
}
|
||||
return child as MockFileSystemFileHandle;
|
||||
}
|
||||
|
||||
async removeEntry(name: string, options?: { recursive?: boolean }): Promise<void> {
|
||||
const child = this.children.get(name);
|
||||
if (!child) {
|
||||
throw new DOMException(`Entry "${name}" not found`, 'NotFoundError');
|
||||
}
|
||||
if (child.kind === 'directory' && child.size() > 0 && !options?.recursive) {
|
||||
throw new DOMException(`Directory "${name}" is not empty`, 'InvalidModificationError');
|
||||
}
|
||||
this.children.delete(name);
|
||||
}
|
||||
|
||||
async *entries(): AsyncIterableIterator<[string, MockFileSystemDirectoryHandle | MockFileSystemFileHandle]> {
|
||||
for (const entry of this.children.entries()) {
|
||||
yield entry;
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.children.clear();
|
||||
}
|
||||
|
||||
size(): number {
|
||||
return this.children.size;
|
||||
}
|
||||
}
|
||||
|
||||
const mockRoot = new MockFileSystemDirectoryHandle('root');
|
||||
|
||||
vi.stubGlobal('navigator', {
|
||||
...navigator,
|
||||
storage: {
|
||||
getDirectory: vi.fn(async () => mockRoot),
|
||||
},
|
||||
});
|
||||
|
||||
describe('SoundfontInstrumentCache', () => {
|
||||
const baseUrl = 'https://cdn.example.com/FluidR3_GM/';
|
||||
const instrumentName = 'test_instrument';
|
||||
const expectedKeys = ['C4', 'Db4'];
|
||||
const blobsByKey = {
|
||||
C4: new Blob([new Uint8Array([1, 2, 3])], { type: 'audio/mpeg' }),
|
||||
Db4: new Blob([new Uint8Array([4, 5, 6])], { type: 'audio/mpeg' }),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockRoot.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('stores and validates a finalized instrument cache', async () => {
|
||||
await SoundfontInstrumentCache.storeInstrument(instrumentName, expectedKeys, blobsByKey, baseUrl);
|
||||
|
||||
const summary = await SoundfontInstrumentCache.getCacheSummary(baseUrl);
|
||||
expect(summary.instrumentCount).toBe(1);
|
||||
expect(summary.instruments).toEqual([instrumentName]);
|
||||
|
||||
const urls = await SoundfontInstrumentCache.getInstrumentObjectUrls(instrumentName, expectedKeys, baseUrl);
|
||||
expect(Object.keys(urls)).toEqual(expectedKeys);
|
||||
Object.values(urls).forEach(url => URL.revokeObjectURL(url));
|
||||
});
|
||||
|
||||
it('rejects incomplete finalize attempts', async () => {
|
||||
await expect(SoundfontInstrumentCache.storeInstrument(
|
||||
instrumentName,
|
||||
expectedKeys,
|
||||
{ C4: blobsByKey.C4 },
|
||||
baseUrl,
|
||||
)).rejects.toThrow(/incomplete key set/i);
|
||||
|
||||
expect(await SoundfontInstrumentCache.exists(instrumentName, expectedKeys, baseUrl)).toBe(false);
|
||||
});
|
||||
|
||||
it('invalidates cached instruments when the base URL changes', async () => {
|
||||
await SoundfontInstrumentCache.storeInstrument(instrumentName, expectedKeys, blobsByKey, baseUrl);
|
||||
expect((await SoundfontInstrumentCache.getCacheSummary(baseUrl)).instrumentCount).toBe(1);
|
||||
|
||||
expect((await SoundfontInstrumentCache.getCacheSummary('https://other.example.com/FluidR3_GM/')).instrumentCount).toBe(0);
|
||||
const summary = await SoundfontInstrumentCache.getCacheSummary('https://other.example.com/FluidR3_GM/');
|
||||
expect(summary.instrumentCount).toBe(0);
|
||||
});
|
||||
|
||||
it('treats broken cached instruments as invalid and removes them', async () => {
|
||||
await SoundfontInstrumentCache.storeInstrument(instrumentName, expectedKeys, blobsByKey, baseUrl);
|
||||
|
||||
const root = await navigator.storage.getDirectory();
|
||||
const soundfontDir = await root.getDirectoryHandle('soundfont');
|
||||
const libraryDir = await soundfontDir.getDirectoryHandle('FluidR3_GM');
|
||||
const instrumentDir = await libraryDir.getDirectoryHandle(instrumentName);
|
||||
await instrumentDir.removeEntry('Db4.mp3');
|
||||
|
||||
expect(await SoundfontInstrumentCache.exists(instrumentName, expectedKeys, baseUrl)).toBe(false);
|
||||
const summary = await SoundfontInstrumentCache.getCacheSummary(baseUrl);
|
||||
expect(summary.instrumentCount).toBe(0);
|
||||
});
|
||||
|
||||
it('deletes all cached instruments', async () => {
|
||||
await SoundfontInstrumentCache.storeInstrument(instrumentName, expectedKeys, blobsByKey, baseUrl);
|
||||
await SoundfontInstrumentCache.storeInstrument('other_instrument', expectedKeys, blobsByKey, baseUrl);
|
||||
|
||||
await SoundfontInstrumentCache.deleteAll();
|
||||
|
||||
const summary = await SoundfontInstrumentCache.getCacheSummary(baseUrl);
|
||||
expect(summary.instrumentCount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
interface SoundfontCacheRootMetadata {
|
||||
baseUrl: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface SoundfontInstrumentMetadata {
|
||||
complete: boolean;
|
||||
keys: string[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SoundfontCacheSummary {
|
||||
instrumentCount: number;
|
||||
instruments: string[];
|
||||
}
|
||||
|
||||
const SOUND_FONT_ROOT_DIR = 'soundfont';
|
||||
const FLUIDR3_DIR = 'FluidR3_GM';
|
||||
const ROOT_METADATA_FILE = 'cache-metadata.json';
|
||||
const INSTRUMENT_METADATA_FILE = 'instrument-metadata.json';
|
||||
|
||||
export class SoundfontInstrumentCache {
|
||||
public static async exists(instrumentName: string, expectedKeys: string[], baseUrl: string): Promise<boolean> {
|
||||
try {
|
||||
const rootDir = await this.ensureLibraryDir(baseUrl);
|
||||
const instrumentDir = await rootDir.getDirectoryHandle(instrumentName);
|
||||
const metadata = await this.readJson<SoundfontInstrumentMetadata>(instrumentDir, INSTRUMENT_METADATA_FILE);
|
||||
if (!metadata?.complete || !this.sameKeys(metadata.keys, expectedKeys)) {
|
||||
await this.deleteInstrument(instrumentName);
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const key of expectedKeys) {
|
||||
const handle = await instrumentDir.getFileHandle(`${key}.mp3`);
|
||||
const file = await handle.getFile();
|
||||
if (file.size <= 0) {
|
||||
await this.deleteInstrument(instrumentName);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static async getInstrumentObjectUrls(
|
||||
instrumentName: string,
|
||||
expectedKeys: string[],
|
||||
baseUrl: string,
|
||||
): Promise<Record<string, string>> {
|
||||
const rootDir = await this.ensureLibraryDir(baseUrl);
|
||||
const instrumentDir = await rootDir.getDirectoryHandle(instrumentName);
|
||||
const urls: Record<string, string> = {};
|
||||
|
||||
for (const key of expectedKeys) {
|
||||
const fileHandle = await instrumentDir.getFileHandle(`${key}.mp3`);
|
||||
const file = await fileHandle.getFile();
|
||||
urls[key] = URL.createObjectURL(file);
|
||||
}
|
||||
|
||||
return urls;
|
||||
}
|
||||
|
||||
public static async storeInstrument(
|
||||
instrumentName: string,
|
||||
expectedKeys: string[],
|
||||
blobsByKey: Record<string, Blob>,
|
||||
baseUrl: string,
|
||||
): Promise<void> {
|
||||
if (!this.sameKeys(Object.keys(blobsByKey), expectedKeys)) {
|
||||
throw new Error(`Cannot finalize soundfont cache for ${instrumentName}: incomplete key set.`);
|
||||
}
|
||||
|
||||
const rootDir = await this.ensureLibraryDir(baseUrl);
|
||||
await this.removeIfExists(rootDir, instrumentName, true);
|
||||
const instrumentDir = await rootDir.getDirectoryHandle(instrumentName, { create: true });
|
||||
|
||||
try {
|
||||
for (const key of expectedKeys) {
|
||||
const fileHandle = await instrumentDir.getFileHandle(`${key}.mp3`, { create: true });
|
||||
const writable = await fileHandle.createWritable();
|
||||
try {
|
||||
await writable.write(blobsByKey[key]);
|
||||
await writable.close();
|
||||
} catch (error) {
|
||||
await writable.abort();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const metadata: SoundfontInstrumentMetadata = {
|
||||
complete: true,
|
||||
keys: [...expectedKeys],
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await this.writeJson(instrumentDir, INSTRUMENT_METADATA_FILE, metadata);
|
||||
} catch (error) {
|
||||
await this.removeIfExists(rootDir, instrumentName, true);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public static async deleteInstrument(instrumentName: string): Promise<void> {
|
||||
try {
|
||||
const rootDir = await this.getLibraryDir(false);
|
||||
if (!rootDir) return;
|
||||
await this.removeIfExists(rootDir, instrumentName, true);
|
||||
} catch {
|
||||
// Ignore missing cache roots during cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
public static async deleteAll(): Promise<void> {
|
||||
try {
|
||||
const rootDir = await this.getLibraryDir(false);
|
||||
if (!rootDir) return;
|
||||
|
||||
for await (const [name] of rootDir.entries()) {
|
||||
await this.removeIfExists(rootDir, name, true);
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup failures for missing cache roots.
|
||||
}
|
||||
}
|
||||
|
||||
public static async getCacheSummary(baseUrl: string): Promise<SoundfontCacheSummary> {
|
||||
const rootDir = await this.ensureLibraryDir(baseUrl);
|
||||
const instruments: string[] = [];
|
||||
|
||||
for await (const [name, entry] of rootDir.entries()) {
|
||||
if (entry.kind !== 'directory') continue;
|
||||
|
||||
try {
|
||||
const metadata = await this.readJson<SoundfontInstrumentMetadata>(entry, INSTRUMENT_METADATA_FILE);
|
||||
if (metadata?.complete) {
|
||||
instruments.push(name);
|
||||
}
|
||||
} catch {
|
||||
// Ignore broken entries in summary output.
|
||||
}
|
||||
}
|
||||
|
||||
instruments.sort();
|
||||
return {
|
||||
instrumentCount: instruments.length,
|
||||
instruments,
|
||||
};
|
||||
}
|
||||
|
||||
private static async ensureLibraryDir(baseUrl: string): Promise<FileSystemDirectoryHandle> {
|
||||
const root = await navigator.storage.getDirectory();
|
||||
const soundfontDir = await root.getDirectoryHandle(SOUND_FONT_ROOT_DIR, { create: true });
|
||||
const libraryDir = await soundfontDir.getDirectoryHandle(FLUIDR3_DIR, { create: true });
|
||||
const metadata = await this.readRootMetadata(libraryDir);
|
||||
|
||||
if (!metadata || metadata.baseUrl !== baseUrl) {
|
||||
for await (const [name] of libraryDir.entries()) {
|
||||
await this.removeIfExists(libraryDir, name, true);
|
||||
}
|
||||
|
||||
const nextMetadata: SoundfontCacheRootMetadata = {
|
||||
baseUrl,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await this.writeJson(libraryDir, ROOT_METADATA_FILE, nextMetadata);
|
||||
}
|
||||
|
||||
return libraryDir;
|
||||
}
|
||||
|
||||
private static async getLibraryDir(create: boolean): Promise<FileSystemDirectoryHandle | null> {
|
||||
try {
|
||||
const root = await navigator.storage.getDirectory();
|
||||
const soundfontDir = await root.getDirectoryHandle(SOUND_FONT_ROOT_DIR, { create });
|
||||
return await soundfontDir.getDirectoryHandle(FLUIDR3_DIR, { create });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async readRootMetadata(dir: FileSystemDirectoryHandle): Promise<SoundfontCacheRootMetadata | null> {
|
||||
try {
|
||||
return await this.readJson<SoundfontCacheRootMetadata>(dir, ROOT_METADATA_FILE);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async readJson<T>(dir: FileSystemDirectoryHandle, filename: string): Promise<T> {
|
||||
const handle = await dir.getFileHandle(filename);
|
||||
const file = await handle.getFile();
|
||||
return JSON.parse(await file.text()) as T;
|
||||
}
|
||||
|
||||
private static async writeJson(dir: FileSystemDirectoryHandle, filename: string, value: unknown): Promise<void> {
|
||||
const handle = await dir.getFileHandle(filename, { create: true });
|
||||
const writable = await handle.createWritable();
|
||||
try {
|
||||
await writable.write(JSON.stringify(value, null, 2));
|
||||
await writable.close();
|
||||
} catch (error) {
|
||||
await writable.abort();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private static sameKeys(actual: string[], expected: string[]): boolean {
|
||||
if (actual.length !== expected.length) return false;
|
||||
const expectedSet = new Set(expected);
|
||||
return actual.every(key => expectedSet.has(key));
|
||||
}
|
||||
|
||||
private static async removeIfExists(
|
||||
dir: FileSystemDirectoryHandle,
|
||||
name: string,
|
||||
recursive: boolean = false,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await dir.removeEntry(name, recursive ? ({ recursive: true } as FileSystemRemoveOptions) : undefined);
|
||||
} catch {
|
||||
// Ignore missing entry cleanup.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user