Added complete project save/load system using IndexedDB: **New Files:** - `lib/storage/db.ts` - IndexedDB database schema and operations - ProjectMetadata interface for project metadata - SerializedAudioBuffer and SerializedTrack for storage - Database initialization with projects object store - Audio buffer serialization/deserialization functions - CRUD operations for projects - `lib/storage/projects.ts` - High-level project management service - Save/load project state with tracks and settings - List all projects sorted by last updated - Delete and duplicate project operations - Track serialization with proper type conversions - Audio buffer and effect chain handling - `components/dialogs/ProjectsDialog.tsx` - Project list UI - Grid view of all projects with metadata - Project actions: Open, Duplicate, Delete - Create new project button - Empty state with call-to-action - Confirmation dialog for deletions **Key Features:** - IndexedDB stores complete project state (tracks, audio buffers, settings) - Efficient serialization of AudioBuffer channel data - Preserves all track properties (effects, automation, volume, pan) - Sample rate and duration tracking - Created/updated timestamps - Type-safe with full TypeScript support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
194 lines
5.0 KiB
TypeScript
194 lines
5.0 KiB
TypeScript
/**
|
|
* IndexedDB database for project storage
|
|
*/
|
|
|
|
export const DB_NAME = 'audio-editor-db';
|
|
export const DB_VERSION = 1;
|
|
|
|
export interface ProjectMetadata {
|
|
id: string;
|
|
name: string;
|
|
description?: string;
|
|
createdAt: number;
|
|
updatedAt: number;
|
|
duration: number; // Total project duration in seconds
|
|
sampleRate: number;
|
|
trackCount: number;
|
|
thumbnail?: string; // Base64 encoded waveform thumbnail
|
|
}
|
|
|
|
export interface SerializedAudioBuffer {
|
|
sampleRate: number;
|
|
length: number;
|
|
numberOfChannels: number;
|
|
channelData: Float32Array[]; // Array of channel data
|
|
}
|
|
|
|
export interface SerializedTrack {
|
|
id: string;
|
|
name: string;
|
|
color: string;
|
|
volume: number;
|
|
pan: number;
|
|
muted: boolean;
|
|
soloed: boolean;
|
|
collapsed: boolean;
|
|
height: number;
|
|
audioBuffer: SerializedAudioBuffer | null;
|
|
effects: any[]; // Effect chain
|
|
automation: any; // Automation data
|
|
recordEnabled: boolean;
|
|
}
|
|
|
|
export interface ProjectData {
|
|
metadata: ProjectMetadata;
|
|
tracks: SerializedTrack[];
|
|
settings: {
|
|
zoom: number;
|
|
currentTime: number;
|
|
sampleRate: number;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Initialize IndexedDB database
|
|
*/
|
|
export function initDB(): Promise<IDBDatabase> {
|
|
return new Promise((resolve, reject) => {
|
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
|
|
|
request.onerror = () => reject(request.error);
|
|
request.onsuccess = () => resolve(request.result);
|
|
|
|
request.onupgradeneeded = (event) => {
|
|
const db = (event.target as IDBOpenDBRequest).result;
|
|
|
|
// Create projects object store
|
|
if (!db.objectStoreNames.contains('projects')) {
|
|
const projectStore = db.createObjectStore('projects', { keyPath: 'metadata.id' });
|
|
projectStore.createIndex('updatedAt', 'metadata.updatedAt', { unique: false });
|
|
projectStore.createIndex('name', 'metadata.name', { unique: false });
|
|
}
|
|
|
|
// Create audio buffers object store (for large files)
|
|
if (!db.objectStoreNames.contains('audioBuffers')) {
|
|
db.createObjectStore('audioBuffers', { keyPath: 'id' });
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get all projects (metadata only for list view)
|
|
*/
|
|
export async function getAllProjects(): Promise<ProjectMetadata[]> {
|
|
const db = await initDB();
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = db.transaction(['projects'], 'readonly');
|
|
const store = transaction.objectStore('projects');
|
|
const index = store.index('updatedAt');
|
|
const request = index.openCursor(null, 'prev'); // Most recent first
|
|
|
|
const projects: ProjectMetadata[] = [];
|
|
|
|
request.onsuccess = () => {
|
|
const cursor = request.result;
|
|
if (cursor) {
|
|
projects.push(cursor.value.metadata);
|
|
cursor.continue();
|
|
} else {
|
|
resolve(projects);
|
|
}
|
|
};
|
|
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Save project to IndexedDB
|
|
*/
|
|
export async function saveProject(project: ProjectData): Promise<void> {
|
|
const db = await initDB();
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = db.transaction(['projects'], 'readwrite');
|
|
const store = transaction.objectStore('projects');
|
|
const request = store.put(project);
|
|
|
|
request.onsuccess = () => resolve();
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Load project from IndexedDB
|
|
*/
|
|
export async function loadProject(projectId: string): Promise<ProjectData | null> {
|
|
const db = await initDB();
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = db.transaction(['projects'], 'readonly');
|
|
const store = transaction.objectStore('projects');
|
|
const request = store.get(projectId);
|
|
|
|
request.onsuccess = () => resolve(request.result || null);
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Delete project from IndexedDB
|
|
*/
|
|
export async function deleteProject(projectId: string): Promise<void> {
|
|
const db = await initDB();
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = db.transaction(['projects'], 'readwrite');
|
|
const store = transaction.objectStore('projects');
|
|
const request = store.delete(projectId);
|
|
|
|
request.onsuccess = () => resolve();
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Serialize AudioBuffer for storage
|
|
*/
|
|
export function serializeAudioBuffer(buffer: AudioBuffer): SerializedAudioBuffer {
|
|
const channelData: Float32Array[] = [];
|
|
|
|
for (let i = 0; i < buffer.numberOfChannels; i++) {
|
|
channelData.push(new Float32Array(buffer.getChannelData(i)));
|
|
}
|
|
|
|
return {
|
|
sampleRate: buffer.sampleRate,
|
|
length: buffer.length,
|
|
numberOfChannels: buffer.numberOfChannels,
|
|
channelData,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Deserialize AudioBuffer from storage
|
|
*/
|
|
export function deserializeAudioBuffer(
|
|
serialized: SerializedAudioBuffer,
|
|
audioContext: AudioContext
|
|
): AudioBuffer {
|
|
const buffer = audioContext.createBuffer(
|
|
serialized.numberOfChannels,
|
|
serialized.length,
|
|
serialized.sampleRate
|
|
);
|
|
|
|
for (let i = 0; i < serialized.numberOfChannels; i++) {
|
|
buffer.copyToChannel(new Float32Array(serialized.channelData[i]), i);
|
|
}
|
|
|
|
return buffer;
|
|
}
|