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
@@ -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">