feat: implement Phase 12.1 - Project Management with IndexedDB

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>
This commit is contained in:
2025-11-19 09:19:52 +01:00
parent e208a448d0
commit e1c19ffcb3
3 changed files with 525 additions and 0 deletions

193
lib/storage/db.ts Normal file
View File

@@ -0,0 +1,193 @@
/**
* 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;
}

190
lib/storage/projects.ts Normal file
View File

@@ -0,0 +1,190 @@
/**
* Project management service
*/
import type { Track } from '@/types/track';
import {
saveProject,
loadProject,
getAllProjects,
deleteProject,
serializeAudioBuffer,
deserializeAudioBuffer,
type ProjectData,
type ProjectMetadata,
type SerializedTrack,
} from './db';
import { getAudioContext } from '../audio/context';
import { generateId } from '../audio/effects/chain';
/**
* Generate unique project ID
*/
export function generateProjectId(): string {
return `project_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
/**
* Convert tracks to serialized format
*/
function serializeTracks(tracks: Track[]): SerializedTrack[] {
return tracks.map(track => ({
id: track.id,
name: track.name,
color: track.color,
volume: track.volume,
pan: track.pan,
muted: track.mute,
soloed: track.solo,
collapsed: track.collapsed,
height: track.height,
audioBuffer: track.audioBuffer ? serializeAudioBuffer(track.audioBuffer) : null,
effects: track.effectChain?.effects || [],
automation: track.automation,
recordEnabled: track.recordEnabled,
}));
}
/**
* Convert serialized tracks back to Track format
*/
function deserializeTracks(serialized: SerializedTrack[]): Track[] {
const audioContext = getAudioContext();
return serialized.map(track => ({
id: track.id,
name: track.name,
color: track.color,
volume: track.volume,
pan: track.pan,
mute: track.muted,
solo: track.soloed,
collapsed: track.collapsed,
height: track.height,
audioBuffer: track.audioBuffer ? deserializeAudioBuffer(track.audioBuffer, audioContext) : null,
effectChain: {
id: generateId(),
name: `${track.name} FX`,
effects: track.effects,
},
automation: track.automation,
recordEnabled: track.recordEnabled,
selected: false,
showEffects: false,
selection: null, // Reset selection on load
}));
}
/**
* Calculate total project duration
*/
function calculateDuration(tracks: Track[]): number {
let maxDuration = 0;
for (const track of tracks) {
if (track.audioBuffer) {
maxDuration = Math.max(maxDuration, track.audioBuffer.duration);
}
}
return maxDuration;
}
/**
* Save current project state
*/
export async function saveCurrentProject(
projectId: string | null,
projectName: string,
tracks: Track[],
settings: {
zoom: number;
currentTime: number;
sampleRate: number;
},
description?: string
): Promise<string> {
const id = projectId || generateProjectId();
const now = Date.now();
const metadata: ProjectMetadata = {
id,
name: projectName,
description,
createdAt: projectId ? (await loadProject(id))?.metadata.createdAt || now : now,
updatedAt: now,
duration: calculateDuration(tracks),
sampleRate: settings.sampleRate,
trackCount: tracks.length,
};
const projectData: ProjectData = {
metadata,
tracks: serializeTracks(tracks),
settings,
};
await saveProject(projectData);
return id;
}
/**
* Load project and restore state
*/
export async function loadProjectById(projectId: string): Promise<{
tracks: Track[];
settings: {
zoom: number;
currentTime: number;
sampleRate: number;
};
metadata: ProjectMetadata;
} | null> {
const project = await loadProject(projectId);
if (!project) return null;
return {
tracks: deserializeTracks(project.tracks),
settings: project.settings,
metadata: project.metadata,
};
}
/**
* Get list of all projects
*/
export async function listProjects(): Promise<ProjectMetadata[]> {
return getAllProjects();
}
/**
* Delete a project
*/
export async function removeProject(projectId: string): Promise<void> {
return deleteProject(projectId);
}
/**
* Duplicate a project
*/
export async function duplicateProject(sourceProjectId: string, newName: string): Promise<string> {
const project = await loadProject(sourceProjectId);
if (!project) throw new Error('Project not found');
const newId = generateProjectId();
const now = Date.now();
const newProject: ProjectData = {
...project,
metadata: {
...project.metadata,
id: newId,
name: newName,
createdAt: now,
updatedAt: now,
},
};
await saveProject(newProject);
return newId;
}