Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 19 additions & 15 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,16 +59,21 @@ function App() {
// --- Library handlers ---

const handleLibraryUpload = async (files: File[], category: 'music' | 'sfx'): Promise<void> => {
const newItems: LibraryItem[] = [];
const duplicates: string[] = [];
const failures: string[] = [];

for (const file of files) {
// Snapshot existing hashes once so we don't restart the scan as items stream in
const existingHashes = new Set(
libraryItems.filter((item) => item.category === category).map((item) => item.hash),
);
// Claimed within this batch, prevents two identical files from both being added
const claimedHashes = new Set<string>();

const processFile = async (file: File): Promise<void> => {
try {
// Read the raw bytes first - needed for both hashing and decoding.
const arrayBuffer = await file.arrayBuffer();

// Hash and metadata extraction can run in parallel.
const [hashBuffer, { title, artist, coverUrl }] = await Promise.all([
crypto.subtle.digest('SHA-256', arrayBuffer),
extractMetadata(file),
Expand All @@ -78,19 +83,16 @@ function App() {
.map((b) => b.toString(16).padStart(2, '0'))
.join('');

const isDuplicate =
libraryItems.some((item) => item.hash === hash && item.category === category) ||
newItems.some((item) => item.hash === hash);

if (isDuplicate) {
if (existingHashes.has(hash) || claimedHashes.has(hash)) {
duplicates.push(file.name);
continue;
return;
}
claimedHashes.add(hash);

const bufferId = crypto.randomUUID();
const audioBuffer = await engine.buffers.add(bufferId, arrayBuffer);

newItems.push({
const newItem: LibraryItem = {
id: bufferId,
title,
filename: file.name,
Expand All @@ -99,15 +101,17 @@ function App() {
duration: Math.round(audioBuffer.duration),
category,
coverUrl,
});
};

// Stream results into the library as each file finishes rather than
// waiting for the whole batch
setLibraryItems((prev) => [...prev, newItem]);
} catch {
failures.push(file.name);
}
}
};

if (newItems.length > 0) {
setLibraryItems((prev) => [...prev, ...newItems]);
}
await Promise.all(files.map(processFile));

if (duplicates.length > 0) {
window.alert(`Skipped duplicate(s): ${duplicates.join(', ')}`);
Expand Down
2 changes: 1 addition & 1 deletion src/components/Timeline/ClipWaveform.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ interface ClipWaveformProps {

/**
* canvas-backed waveform that reads pre-computed peaks from engine.waveforms.
* redraw cost is O(widthPx) regardless of clip length or zoom level — the
* redraw cost is O(widthPx) regardless of clip length or zoom level.
* mipmap level is chosen by WaveformCache to match the requested bucket count.
*/
export default function ClipWaveform({
Expand Down
Loading