feat: cache soundfont MP3s in OPFS and add cache controls
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SoundfontInstrumentCache } from './soundfontInstrumentCache';
|
||||
|
||||
class MockWritableFileStream {
|
||||
private readonly handle: MockFileSystemFileHandle;
|
||||
private chunks: Uint8Array[] = [];
|
||||
|
||||
constructor(handle: MockFileSystemFileHandle) {
|
||||
this.handle = handle;
|
||||
}
|
||||
|
||||
async write(content: Blob | ArrayBuffer | ArrayBufferView | string): Promise<void> {
|
||||
let bytes: Uint8Array;
|
||||
if (typeof content === 'string') {
|
||||
bytes = new TextEncoder().encode(content);
|
||||
} else if (typeof (content as Blob).arrayBuffer === 'function') {
|
||||
bytes = new Uint8Array(await (content as Blob).arrayBuffer());
|
||||
} else if (content instanceof ArrayBuffer) {
|
||||
bytes = new Uint8Array(content);
|
||||
} else {
|
||||
const view = content as ArrayBufferView;
|
||||
bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
|
||||
}
|
||||
this.chunks.push(new Uint8Array(bytes));
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
const total = this.chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
|
||||
const merged = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of this.chunks) {
|
||||
merged.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
this.handle.setContent(merged);
|
||||
}
|
||||
|
||||
async abort(): Promise<void> {
|
||||
this.chunks = [];
|
||||
}
|
||||
}
|
||||
|
||||
class MockFileSystemFileHandle {
|
||||
kind = 'file' as const;
|
||||
private content = new Uint8Array();
|
||||
|
||||
constructor(public readonly name: string) {}
|
||||
|
||||
setContent(content: Uint8Array): void {
|
||||
this.content = content;
|
||||
}
|
||||
|
||||
async getFile(): Promise<File> {
|
||||
return {
|
||||
size: this.content.byteLength,
|
||||
text: async () => new TextDecoder().decode(this.content),
|
||||
} as unknown as File;
|
||||
}
|
||||
|
||||
async createWritable(): Promise<MockWritableFileStream> {
|
||||
return new MockWritableFileStream(this);
|
||||
}
|
||||
}
|
||||
|
||||
class MockFileSystemDirectoryHandle {
|
||||
kind = 'directory' as const;
|
||||
private children = new Map<string, MockFileSystemDirectoryHandle | MockFileSystemFileHandle>();
|
||||
|
||||
constructor(public readonly name: string) {}
|
||||
|
||||
async getDirectoryHandle(name: string, options?: { create?: boolean }): Promise<MockFileSystemDirectoryHandle> {
|
||||
let child = this.children.get(name);
|
||||
if (!child || child.kind !== 'directory') {
|
||||
if (!options?.create) {
|
||||
throw new DOMException(`Directory "${name}" not found`, 'NotFoundError');
|
||||
}
|
||||
child = new MockFileSystemDirectoryHandle(name);
|
||||
this.children.set(name, child);
|
||||
}
|
||||
return child as MockFileSystemDirectoryHandle;
|
||||
}
|
||||
|
||||
async getFileHandle(name: string, options?: { create?: boolean }): Promise<MockFileSystemFileHandle> {
|
||||
let child = this.children.get(name);
|
||||
if (!child || child.kind !== 'file') {
|
||||
if (!options?.create) {
|
||||
throw new DOMException(`File "${name}" not found`, 'NotFoundError');
|
||||
}
|
||||
child = new MockFileSystemFileHandle(name);
|
||||
this.children.set(name, child);
|
||||
}
|
||||
return child as MockFileSystemFileHandle;
|
||||
}
|
||||
|
||||
async removeEntry(name: string, options?: { recursive?: boolean }): Promise<void> {
|
||||
const child = this.children.get(name);
|
||||
if (!child) {
|
||||
throw new DOMException(`Entry "${name}" not found`, 'NotFoundError');
|
||||
}
|
||||
if (child.kind === 'directory' && child.size() > 0 && !options?.recursive) {
|
||||
throw new DOMException(`Directory "${name}" is not empty`, 'InvalidModificationError');
|
||||
}
|
||||
this.children.delete(name);
|
||||
}
|
||||
|
||||
async *entries(): AsyncIterableIterator<[string, MockFileSystemDirectoryHandle | MockFileSystemFileHandle]> {
|
||||
for (const entry of this.children.entries()) {
|
||||
yield entry;
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.children.clear();
|
||||
}
|
||||
|
||||
size(): number {
|
||||
return this.children.size;
|
||||
}
|
||||
}
|
||||
|
||||
const mockRoot = new MockFileSystemDirectoryHandle('root');
|
||||
|
||||
vi.stubGlobal('navigator', {
|
||||
...navigator,
|
||||
storage: {
|
||||
getDirectory: vi.fn(async () => mockRoot),
|
||||
},
|
||||
});
|
||||
|
||||
describe('SoundfontInstrumentCache', () => {
|
||||
const baseUrl = 'https://cdn.example.com/FluidR3_GM/';
|
||||
const instrumentName = 'test_instrument';
|
||||
const expectedKeys = ['C4', 'Db4'];
|
||||
const blobsByKey = {
|
||||
C4: new Blob([new Uint8Array([1, 2, 3])], { type: 'audio/mpeg' }),
|
||||
Db4: new Blob([new Uint8Array([4, 5, 6])], { type: 'audio/mpeg' }),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockRoot.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('stores and validates a finalized instrument cache', async () => {
|
||||
await SoundfontInstrumentCache.storeInstrument(instrumentName, expectedKeys, blobsByKey, baseUrl);
|
||||
|
||||
const summary = await SoundfontInstrumentCache.getCacheSummary(baseUrl);
|
||||
expect(summary.instrumentCount).toBe(1);
|
||||
expect(summary.instruments).toEqual([instrumentName]);
|
||||
|
||||
const urls = await SoundfontInstrumentCache.getInstrumentObjectUrls(instrumentName, expectedKeys, baseUrl);
|
||||
expect(Object.keys(urls)).toEqual(expectedKeys);
|
||||
Object.values(urls).forEach(url => URL.revokeObjectURL(url));
|
||||
});
|
||||
|
||||
it('rejects incomplete finalize attempts', async () => {
|
||||
await expect(SoundfontInstrumentCache.storeInstrument(
|
||||
instrumentName,
|
||||
expectedKeys,
|
||||
{ C4: blobsByKey.C4 },
|
||||
baseUrl,
|
||||
)).rejects.toThrow(/incomplete key set/i);
|
||||
|
||||
expect(await SoundfontInstrumentCache.exists(instrumentName, expectedKeys, baseUrl)).toBe(false);
|
||||
});
|
||||
|
||||
it('invalidates cached instruments when the base URL changes', async () => {
|
||||
await SoundfontInstrumentCache.storeInstrument(instrumentName, expectedKeys, blobsByKey, baseUrl);
|
||||
expect((await SoundfontInstrumentCache.getCacheSummary(baseUrl)).instrumentCount).toBe(1);
|
||||
|
||||
expect((await SoundfontInstrumentCache.getCacheSummary('https://other.example.com/FluidR3_GM/')).instrumentCount).toBe(0);
|
||||
const summary = await SoundfontInstrumentCache.getCacheSummary('https://other.example.com/FluidR3_GM/');
|
||||
expect(summary.instrumentCount).toBe(0);
|
||||
});
|
||||
|
||||
it('treats broken cached instruments as invalid and removes them', async () => {
|
||||
await SoundfontInstrumentCache.storeInstrument(instrumentName, expectedKeys, blobsByKey, baseUrl);
|
||||
|
||||
const root = await navigator.storage.getDirectory();
|
||||
const soundfontDir = await root.getDirectoryHandle('soundfont');
|
||||
const libraryDir = await soundfontDir.getDirectoryHandle('FluidR3_GM');
|
||||
const instrumentDir = await libraryDir.getDirectoryHandle(instrumentName);
|
||||
await instrumentDir.removeEntry('Db4.mp3');
|
||||
|
||||
expect(await SoundfontInstrumentCache.exists(instrumentName, expectedKeys, baseUrl)).toBe(false);
|
||||
const summary = await SoundfontInstrumentCache.getCacheSummary(baseUrl);
|
||||
expect(summary.instrumentCount).toBe(0);
|
||||
});
|
||||
|
||||
it('deletes all cached instruments', async () => {
|
||||
await SoundfontInstrumentCache.storeInstrument(instrumentName, expectedKeys, blobsByKey, baseUrl);
|
||||
await SoundfontInstrumentCache.storeInstrument('other_instrument', expectedKeys, blobsByKey, baseUrl);
|
||||
|
||||
await SoundfontInstrumentCache.deleteAll();
|
||||
|
||||
const summary = await SoundfontInstrumentCache.getCacheSummary(baseUrl);
|
||||
expect(summary.instrumentCount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
interface SoundfontCacheRootMetadata {
|
||||
baseUrl: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface SoundfontInstrumentMetadata {
|
||||
complete: boolean;
|
||||
keys: string[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SoundfontCacheSummary {
|
||||
instrumentCount: number;
|
||||
instruments: string[];
|
||||
}
|
||||
|
||||
const SOUND_FONT_ROOT_DIR = 'soundfont';
|
||||
const FLUIDR3_DIR = 'FluidR3_GM';
|
||||
const ROOT_METADATA_FILE = 'cache-metadata.json';
|
||||
const INSTRUMENT_METADATA_FILE = 'instrument-metadata.json';
|
||||
|
||||
export class SoundfontInstrumentCache {
|
||||
public static async exists(instrumentName: string, expectedKeys: string[], baseUrl: string): Promise<boolean> {
|
||||
try {
|
||||
const rootDir = await this.ensureLibraryDir(baseUrl);
|
||||
const instrumentDir = await rootDir.getDirectoryHandle(instrumentName);
|
||||
const metadata = await this.readJson<SoundfontInstrumentMetadata>(instrumentDir, INSTRUMENT_METADATA_FILE);
|
||||
if (!metadata?.complete || !this.sameKeys(metadata.keys, expectedKeys)) {
|
||||
await this.deleteInstrument(instrumentName);
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const key of expectedKeys) {
|
||||
const handle = await instrumentDir.getFileHandle(`${key}.mp3`);
|
||||
const file = await handle.getFile();
|
||||
if (file.size <= 0) {
|
||||
await this.deleteInstrument(instrumentName);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static async getInstrumentObjectUrls(
|
||||
instrumentName: string,
|
||||
expectedKeys: string[],
|
||||
baseUrl: string,
|
||||
): Promise<Record<string, string>> {
|
||||
const rootDir = await this.ensureLibraryDir(baseUrl);
|
||||
const instrumentDir = await rootDir.getDirectoryHandle(instrumentName);
|
||||
const urls: Record<string, string> = {};
|
||||
|
||||
for (const key of expectedKeys) {
|
||||
const fileHandle = await instrumentDir.getFileHandle(`${key}.mp3`);
|
||||
const file = await fileHandle.getFile();
|
||||
urls[key] = URL.createObjectURL(file);
|
||||
}
|
||||
|
||||
return urls;
|
||||
}
|
||||
|
||||
public static async storeInstrument(
|
||||
instrumentName: string,
|
||||
expectedKeys: string[],
|
||||
blobsByKey: Record<string, Blob>,
|
||||
baseUrl: string,
|
||||
): Promise<void> {
|
||||
if (!this.sameKeys(Object.keys(blobsByKey), expectedKeys)) {
|
||||
throw new Error(`Cannot finalize soundfont cache for ${instrumentName}: incomplete key set.`);
|
||||
}
|
||||
|
||||
const rootDir = await this.ensureLibraryDir(baseUrl);
|
||||
await this.removeIfExists(rootDir, instrumentName, true);
|
||||
const instrumentDir = await rootDir.getDirectoryHandle(instrumentName, { create: true });
|
||||
|
||||
try {
|
||||
for (const key of expectedKeys) {
|
||||
const fileHandle = await instrumentDir.getFileHandle(`${key}.mp3`, { create: true });
|
||||
const writable = await fileHandle.createWritable();
|
||||
try {
|
||||
await writable.write(blobsByKey[key]);
|
||||
await writable.close();
|
||||
} catch (error) {
|
||||
await writable.abort();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const metadata: SoundfontInstrumentMetadata = {
|
||||
complete: true,
|
||||
keys: [...expectedKeys],
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await this.writeJson(instrumentDir, INSTRUMENT_METADATA_FILE, metadata);
|
||||
} catch (error) {
|
||||
await this.removeIfExists(rootDir, instrumentName, true);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public static async deleteInstrument(instrumentName: string): Promise<void> {
|
||||
try {
|
||||
const rootDir = await this.getLibraryDir(false);
|
||||
if (!rootDir) return;
|
||||
await this.removeIfExists(rootDir, instrumentName, true);
|
||||
} catch {
|
||||
// Ignore missing cache roots during cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
public static async deleteAll(): Promise<void> {
|
||||
try {
|
||||
const rootDir = await this.getLibraryDir(false);
|
||||
if (!rootDir) return;
|
||||
|
||||
for await (const [name] of rootDir.entries()) {
|
||||
await this.removeIfExists(rootDir, name, true);
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup failures for missing cache roots.
|
||||
}
|
||||
}
|
||||
|
||||
public static async getCacheSummary(baseUrl: string): Promise<SoundfontCacheSummary> {
|
||||
const rootDir = await this.ensureLibraryDir(baseUrl);
|
||||
const instruments: string[] = [];
|
||||
|
||||
for await (const [name, entry] of rootDir.entries()) {
|
||||
if (entry.kind !== 'directory') continue;
|
||||
|
||||
try {
|
||||
const metadata = await this.readJson<SoundfontInstrumentMetadata>(entry, INSTRUMENT_METADATA_FILE);
|
||||
if (metadata?.complete) {
|
||||
instruments.push(name);
|
||||
}
|
||||
} catch {
|
||||
// Ignore broken entries in summary output.
|
||||
}
|
||||
}
|
||||
|
||||
instruments.sort();
|
||||
return {
|
||||
instrumentCount: instruments.length,
|
||||
instruments,
|
||||
};
|
||||
}
|
||||
|
||||
private static async ensureLibraryDir(baseUrl: string): Promise<FileSystemDirectoryHandle> {
|
||||
const root = await navigator.storage.getDirectory();
|
||||
const soundfontDir = await root.getDirectoryHandle(SOUND_FONT_ROOT_DIR, { create: true });
|
||||
const libraryDir = await soundfontDir.getDirectoryHandle(FLUIDR3_DIR, { create: true });
|
||||
const metadata = await this.readRootMetadata(libraryDir);
|
||||
|
||||
if (!metadata || metadata.baseUrl !== baseUrl) {
|
||||
for await (const [name] of libraryDir.entries()) {
|
||||
await this.removeIfExists(libraryDir, name, true);
|
||||
}
|
||||
|
||||
const nextMetadata: SoundfontCacheRootMetadata = {
|
||||
baseUrl,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await this.writeJson(libraryDir, ROOT_METADATA_FILE, nextMetadata);
|
||||
}
|
||||
|
||||
return libraryDir;
|
||||
}
|
||||
|
||||
private static async getLibraryDir(create: boolean): Promise<FileSystemDirectoryHandle | null> {
|
||||
try {
|
||||
const root = await navigator.storage.getDirectory();
|
||||
const soundfontDir = await root.getDirectoryHandle(SOUND_FONT_ROOT_DIR, { create });
|
||||
return await soundfontDir.getDirectoryHandle(FLUIDR3_DIR, { create });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async readRootMetadata(dir: FileSystemDirectoryHandle): Promise<SoundfontCacheRootMetadata | null> {
|
||||
try {
|
||||
return await this.readJson<SoundfontCacheRootMetadata>(dir, ROOT_METADATA_FILE);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async readJson<T>(dir: FileSystemDirectoryHandle, filename: string): Promise<T> {
|
||||
const handle = await dir.getFileHandle(filename);
|
||||
const file = await handle.getFile();
|
||||
return JSON.parse(await file.text()) as T;
|
||||
}
|
||||
|
||||
private static async writeJson(dir: FileSystemDirectoryHandle, filename: string, value: unknown): Promise<void> {
|
||||
const handle = await dir.getFileHandle(filename, { create: true });
|
||||
const writable = await handle.createWritable();
|
||||
try {
|
||||
await writable.write(JSON.stringify(value, null, 2));
|
||||
await writable.close();
|
||||
} catch (error) {
|
||||
await writable.abort();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private static sameKeys(actual: string[], expected: string[]): boolean {
|
||||
if (actual.length !== expected.length) return false;
|
||||
const expectedSet = new Set(expected);
|
||||
return actual.every(key => expectedSet.has(key));
|
||||
}
|
||||
|
||||
private static async removeIfExists(
|
||||
dir: FileSystemDirectoryHandle,
|
||||
name: string,
|
||||
recursive: boolean = false,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await dir.removeEntry(name, recursive ? ({ recursive: true } as FileSystemRemoveOptions) : undefined);
|
||||
} catch {
|
||||
// Ignore missing entry cleanup.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user