/** * Project management service */ import type { Track } from '@/types/track'; import { saveProject, loadProject, getAllProjects, deleteProject, serializeAudioBuffer, deserializeAudioBuffer, type ProjectData, type SerializedTrack, type SerializedAudioBuffer, } from './db'; import type { ProjectMetadata } from './db'; import { getAudioContext } from '../audio/context'; import { generateId } from '../audio/effects/chain'; // Re-export ProjectMetadata for easier importing export type { ProjectMetadata } from './db'; /** * Generate unique project ID */ export function generateProjectId(): string { return `project_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } /** * Serialize effects by removing any non-serializable data (functions, nodes, etc.) */ function serializeEffects(effects: any[]): any[] { return effects.map(effect => ({ id: effect.id, type: effect.type, name: effect.name, enabled: effect.enabled, expanded: effect.expanded, parameters: effect.parameters ? JSON.parse(JSON.stringify(effect.parameters)) : undefined, })); } /** * Convert tracks to serialized format */ function serializeTracks(tracks: Track[]): SerializedTrack[] { return tracks.map(track => { // Serialize automation by deep cloning to remove any functions const automation = track.automation ? JSON.parse(JSON.stringify(track.automation)) : { lanes: [], showAutomation: false }; return { 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: serializeEffects(track.effectChain?.effects || []), 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 { 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 { return getAllProjects(); } /** * Delete a project */ export async function removeProject(projectId: string): Promise { return deleteProject(projectId); } /** * Duplicate a project */ export async function duplicateProject(sourceProjectId: string, newName: string): Promise { 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; } /** * Export project as ZIP file with separate audio files */ export async function exportProjectAsJSON(projectId: string): Promise { const JSZip = (await import('jszip')).default; const project = await loadProject(projectId); if (!project) throw new Error('Project not found'); const zip = new JSZip(); const audioContext = getAudioContext(); // Create metadata without audio buffers const metadata = { ...project, tracks: project.tracks.map((track, index) => ({ ...track, audioBuffer: track.audioBuffer ? { fileName: `track_${index}.wav`, sampleRate: track.audioBuffer.sampleRate, length: track.audioBuffer.length, numberOfChannels: track.audioBuffer.numberOfChannels, } : null, })), }; // Add project.json to ZIP zip.file('project.json', JSON.stringify(metadata, null, 2)); // Convert audio buffers to WAV and add to ZIP for (let i = 0; i < project.tracks.length; i++) { const track = project.tracks[i]; if (track.audioBuffer) { // Deserialize audio buffer const buffer = deserializeAudioBuffer(track.audioBuffer, audioContext); // Convert to WAV const { audioBufferToWav } = await import('@/lib/audio/export'); const wavBlob = await audioBufferToWav(buffer); // Add to ZIP zip.file(`track_${i}.wav`, wavBlob); } } // Generate ZIP and download const zipBlob = await zip.generateAsync({ type: 'blob' }); const url = URL.createObjectURL(zipBlob); const a = document.createElement('a'); a.href = url; a.download = `${project.metadata.name.replace(/[^a-z0-9]/gi, '_').toLowerCase()}_${Date.now()}.zip`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } /** * Import project from ZIP file */ export async function importProjectFromJSON(file: File): Promise { const JSZip = (await import('jszip')).default; try { const zip = await JSZip.loadAsync(file); // Read project.json const projectJsonFile = zip.file('project.json'); if (!projectJsonFile) throw new Error('Invalid project file: missing project.json'); const projectJson = await projectJsonFile.async('text'); const metadata = JSON.parse(projectJson); // Read audio files and reconstruct tracks const audioContext = getAudioContext(); const tracks: SerializedTrack[] = []; for (let i = 0; i < metadata.tracks.length; i++) { const trackMeta = metadata.tracks[i]; let audioBuffer: SerializedAudioBuffer | null = null; if (trackMeta.audioBuffer?.fileName) { const audioFile = zip.file(trackMeta.audioBuffer.fileName); if (audioFile) { // Read WAV file as array buffer const arrayBuffer = await audioFile.async('arraybuffer'); // Decode audio data const decodedBuffer = await audioContext.decodeAudioData(arrayBuffer); // Serialize for storage audioBuffer = serializeAudioBuffer(decodedBuffer); } } tracks.push({ ...trackMeta, audioBuffer, }); } // Generate new ID to avoid conflicts const newId = generateProjectId(); const now = Date.now(); const importedProject: ProjectData = { ...metadata, tracks, metadata: { ...metadata.metadata, id: newId, name: `${metadata.metadata.name} (Imported)`, createdAt: now, updatedAt: now, }, }; await saveProject(importedProject); return newId; } catch (error) { console.error('Import error:', error); throw new Error('Failed to import project file'); } }