Files
audio-ui/lib/storage/projects.ts
Sebastian Krüger 7de75f7b2b refactor: export/import projects as ZIP files instead of JSON
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>
2025-11-19 10:30:20 +01:00

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');
}
}