From 86ec455dfebef3d4f47c963801e1c15b411aadbc Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:11:25 -0700 Subject: [PATCH] feat: cache soundfont MP3s in OPFS and add cache controls --- .../sections/GeneralSettings.test.tsx | 62 +++++ .../settings/sections/GeneralSettings.tsx | 136 +++++++++- .../audio-interface/KGToneBuffersPool.test.ts | 188 ++++++++++++++ src/core/audio-interface/KGToneBuffersPool.ts | 244 ++++++++++++++---- src/i18n/messages/en_us.ts | 10 + src/i18n/messages/fr_fr.ts | 10 + src/i18n/messages/zh_cn.ts | 10 + src/i18n/messages/zh_hk.ts | 10 + src/util/soundfontInstrumentCache.test.ts | 199 ++++++++++++++ src/util/soundfontInstrumentCache.ts | 226 ++++++++++++++++ 10 files changed, 1040 insertions(+), 55 deletions(-) create mode 100644 src/core/audio-interface/KGToneBuffersPool.test.ts create mode 100644 src/util/soundfontInstrumentCache.test.ts create mode 100644 src/util/soundfontInstrumentCache.ts diff --git a/src/components/settings/sections/GeneralSettings.test.tsx b/src/components/settings/sections/GeneralSettings.test.tsx index 6e2f8d0..b2cf163 100644 --- a/src/components/settings/sections/GeneralSettings.test.tsx +++ b/src/components/settings/sections/GeneralSettings.test.tsx @@ -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([ ['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( { 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(); + }); + }); }); diff --git a/src/components/settings/sections/GeneralSettings.tsx b/src/components/settings/sections/GeneralSettings.tsx index 33354c5..7d4f8c9 100644 --- a/src/components/settings/sections/GeneralSettings.tsx +++ b/src/components/settings/sections/GeneralSettings.tsx @@ -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(''); const [kgoneServerManaged, setKgoneServerManaged] = useState(false); const [soundfontServerManaged, setSoundfontServerManaged] = useState(false); + const [soundfontCacheSummary, setSoundfontCacheSummary] = useState({ instrumentCount: 0, instruments: [] }); + const [isCheckingSoundfontCache, setIsCheckingSoundfontCache] = useState(false); + const [isDeletingSoundfontCache, setIsDeletingSoundfontCache] = useState(false); + const [selectedCachedSoundfontInstrument, setSelectedCachedSoundfontInstrument] = useState(''); + const [isDeletingCachedSoundfontInstrument, setIsDeletingCachedSoundfontInstrument] = useState(false); const [localContextLength, setLocalContextLength] = useState(LOCAL_LLM_DEFAULT_CONTEXT_LENGTH); const [localModelState, setLocalModelState] = useState(LocalLLMModelManager.getState()); const [localModelUrl, setLocalModelUrl] = useState(''); @@ -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 = () => { + +
+ +
+ {isCheckingSoundfontCache + ? t('settings.general.soundfont.cacheChecking') + : soundfontCacheSummary.instrumentCount > 0 + ? t('settings.general.soundfont.cacheReady', { count: soundfontCacheSummary.instrumentCount }) + : t('settings.general.soundfont.cacheEmpty')} +
+
+ {t('settings.general.soundfont.cacheHelp')} +
+
+ +
+ + +
+ {t('settings.general.soundfont.cachedInstrumentHelp')} +
+
+ +
+
+ + + +
+
diff --git a/src/core/audio-interface/KGToneBuffersPool.test.ts b/src/core/audio-interface/KGToneBuffersPool.test.ts new file mode 100644 index 0000000..1f2dafa --- /dev/null +++ b/src/core/audio-interface/KGToneBuffersPool.test.ts @@ -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; + 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; + 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); + }); +}); diff --git a/src/core/audio-interface/KGToneBuffersPool.ts b/src/core/audio-interface/KGToneBuffersPool.ts index 4223fbd..46e87ae 100644 --- a/src/core/audio-interface/KGToneBuffersPool.ts +++ b/src/core/audio-interface/KGToneBuffersPool.ts @@ -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 { + 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 { - const configManager = ConfigManager.instance(); - if (!configManager.getIsInitialized()) { - await configManager.initialize(); + private async createToneAudioBuffers(name: string, baseUrl: string): Promise { + 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; + + 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): Promise<{ + successfulBlobs: Record; + failures: string[]; + }> { + const entries = Object.entries(urls); + const successfulBlobs: Record = {}; + 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 { + 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, name: string): Promise { + 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): void { + Object.values(urls).forEach(url => { + if (url.startsWith('blob:')) { + URL.revokeObjectURL(url); + } + }); + } + + private async getSoundfontBaseUrl(): Promise { + const configManager = ConfigManager.instance(); + if (!configManager.getIsInitialized()) { + await configManager.initialize(); + } + + return (configManager.get('general.soundfont.base_url') as string) + || SAMPLER_CONSTANTS.TONE_SAMPLERS.FLUID.url; + } + + private ensureMemoryCacheMatchesBaseUrl(baseUrl: string): void { + if (this.activeBaseUrl === baseUrl) { + return; + } + + this.activeBaseUrl = baseUrl; + this.bufferMap.forEach((buffers, name) => { + try { + buffers.dispose(); + console.log(`Disposed ToneAudioBuffers for ${name} due to soundfont base URL change`); + } catch (error) { + console.error(`Error disposing ToneAudioBuffers for ${name} during soundfont base URL change:`, error); + } + }); + this.bufferMap.clear(); + } + /** * Clear all cached buffers and dispose of resources */ @@ -222,4 +358,4 @@ export class KGToneBuffersPool { await Promise.allSettled(loadPromises); console.log(`Preloading completed for ${instrumentNames.length} instruments`); } -} \ No newline at end of file +} diff --git a/src/i18n/messages/en_us.ts b/src/i18n/messages/en_us.ts index a44e376..ba27541 100644 --- a/src/i18n/messages/en_us.ts +++ b/src/i18n/messages/en_us.ts @@ -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', diff --git a/src/i18n/messages/fr_fr.ts b/src/i18n/messages/fr_fr.ts index 60a4806..4bd5ec3 100644 --- a/src/i18n/messages/fr_fr.ts +++ b/src/i18n/messages/fr_fr.ts @@ -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', diff --git a/src/i18n/messages/zh_cn.ts b/src/i18n/messages/zh_cn.ts index 78e4aaa..e02dbb6 100644 --- a/src/i18n/messages/zh_cn.ts +++ b/src/i18n/messages/zh_cn.ts @@ -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 集成', diff --git a/src/i18n/messages/zh_hk.ts b/src/i18n/messages/zh_hk.ts index b58922d..b61c6d1 100644 --- a/src/i18n/messages/zh_hk.ts +++ b/src/i18n/messages/zh_hk.ts @@ -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 集成', diff --git a/src/util/soundfontInstrumentCache.test.ts b/src/util/soundfontInstrumentCache.test.ts new file mode 100644 index 0000000..71fd0cf --- /dev/null +++ b/src/util/soundfontInstrumentCache.test.ts @@ -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 { + 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 { + 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 { + 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 { + return { + size: this.content.byteLength, + text: async () => new TextDecoder().decode(this.content), + } as unknown as File; + } + + async createWritable(): Promise { + return new MockWritableFileStream(this); + } +} + +class MockFileSystemDirectoryHandle { + kind = 'directory' as const; + private children = new Map(); + + constructor(public readonly name: string) {} + + async getDirectoryHandle(name: string, options?: { create?: boolean }): Promise { + 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 { + 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 { + 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); + }); +}); diff --git a/src/util/soundfontInstrumentCache.ts b/src/util/soundfontInstrumentCache.ts new file mode 100644 index 0000000..20e190e --- /dev/null +++ b/src/util/soundfontInstrumentCache.ts @@ -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 { + try { + const rootDir = await this.ensureLibraryDir(baseUrl); + const instrumentDir = await rootDir.getDirectoryHandle(instrumentName); + const metadata = await this.readJson(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> { + const rootDir = await this.ensureLibraryDir(baseUrl); + const instrumentDir = await rootDir.getDirectoryHandle(instrumentName); + const urls: Record = {}; + + 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, + baseUrl: string, + ): Promise { + 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 { + 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 { + 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 { + 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(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 { + 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 { + 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 { + try { + return await this.readJson(dir, ROOT_METADATA_FILE); + } catch { + return null; + } + } + + private static async readJson(dir: FileSystemDirectoryHandle, filename: string): Promise { + 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 { + 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 { + try { + await dir.removeEntry(name, recursive ? ({ recursive: true } as FileSystemRemoveOptions) : undefined); + } catch { + // Ignore missing entry cleanup. + } + } +}