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">
@@ -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);
});
});
+189 -53
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
*/
+10
View File
@@ -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 instruments 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',
+10
View File
@@ -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 lOPFS du navigateur et réutilisés lors des prochains chargements jusqu’à un changement dURL 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 linstrument 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',
+10
View File
@@ -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 集成',
+10
View File
@@ -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 集成',
+199
View File
@@ -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);
});
});
+226
View File
@@ -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.
}
}
}