Changed from single JSON file to ZIP archive format to avoid string length limits when serializing large audio buffers. New ZIP structure: - project.json (metadata, track info, effects, automation) - track_0.wav, track_1.wav, etc. (audio files in WAV format) Benefits: - No more RangeError: Invalid string length - Smaller file sizes (WAV compression vs base64 JSON) - Human-readable audio files in standard format - Can extract and inspect individual tracks - Easier to edit/debug project metadata Added jszip dependency for ZIP file handling. Changed file picker to accept .zip files. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
338 lines
8.6 KiB
TypeScript
338 lines
8.6 KiB
TypeScript
/**
|
|
* 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<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;
|
|
}
|
|
|
|
/**
|
|
* Export project as ZIP file with separate audio files
|
|
*/
|
|
export async function exportProjectAsJSON(projectId: string): Promise<void> {
|
|
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<string> {
|
|
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');
|
|
}
|
|
}
|