diff --git a/src/main/MainApp.ts b/src/main/MainApp.ts index d4cfeb5..5d384b1 100644 --- a/src/main/MainApp.ts +++ b/src/main/MainApp.ts @@ -161,6 +161,7 @@ export class MainApp { ipcMain.removeHandler(IPC_CHANNELS.libraryUpdateMetadata); ipcMain.removeHandler(IPC_CHANNELS.librarySplit); ipcMain.removeHandler(IPC_CHANNELS.libraryWaveformPreview); + ipcMain.removeHandler(IPC_CHANNELS.libraryWaveformRange); ipcMain.removeHandler(IPC_CHANNELS.libraryImport); ipcMain.removeHandler(IPC_CHANNELS.tagsUpdate); ipcMain.removeHandler(IPC_CHANNELS.categoriesList); @@ -313,6 +314,12 @@ export class MainApp { this.requireLibrary().getWaveformPreview(fileId, pointCount) ); + ipcMain.handle( + IPC_CHANNELS.libraryWaveformRange, + async (_event: IpcMainInvokeEvent, fileId: number, startMs: number, endMs: number) => + this.requireLibrary().getWaveformRange(fileId, startMs, endMs) + ); + ipcMain.handle(IPC_CHANNELS.tagsUpdate, async (_event: IpcMainInvokeEvent, payload: TagUpdatePayload) => this.requireLibrary().updateTagging(payload) ); diff --git a/src/main/services/DatabaseService.ts b/src/main/services/DatabaseService.ts index 7eb347d..79b6a5a 100644 --- a/src/main/services/DatabaseService.ts +++ b/src/main/services/DatabaseService.ts @@ -410,6 +410,51 @@ export class DatabaseService { }; } + /** + * Gets cached waveform data for a file. + */ + public getWaveformCache(fileId: number, pointCount: number): { samples: number[]; rms: number } | null { + const connection = this.requireDb(); + const row = connection + .prepare('SELECT waveform_cache FROM files WHERE id = ?') + .get(fileId) as { waveform_cache: string | null } | undefined; + + if (!row || !row.waveform_cache) { + return null; + } + + try { + const parsed = JSON.parse(row.waveform_cache) as { pointCount: number; samples: number[]; rms: number }; + if (parsed.pointCount === pointCount) { + return { samples: parsed.samples, rms: parsed.rms }; + } + return null; + } catch { + return null; + } + } + + /** + * Sets cached waveform data for a file. + */ + public setWaveformCache(fileId: number, pointCount: number, samples: number[], rms: number): void { + const connection = this.requireDb(); + const cacheData = JSON.stringify({ pointCount, samples, rms }); + connection + .prepare('UPDATE files SET waveform_cache = ? WHERE id = ?') + .run(cacheData, fileId); + } + + /** + * Clears waveform cache for a specific file. + */ + public clearWaveformCache(fileId: number): void { + const connection = this.requireDb(); + connection + .prepare('UPDATE files SET waveform_cache = NULL WHERE id = ?') + .run(fileId); + } + /** * Maps a raw database row to the strongly typed summary shape. */ @@ -500,6 +545,7 @@ export class DatabaseService { this.addColumnIfMissing(connection, 'files', 'checksum', 'TEXT'); this.addColumnIfMissing(connection, 'files', 'custom_name', 'TEXT'); this.addColumnIfMissing(connection, 'files', 'parent_file_id', 'INTEGER'); + this.addColumnIfMissing(connection, 'files', 'waveform_cache', 'TEXT'); // Create checksum index after ensuring column exists connection.exec('CREATE INDEX IF NOT EXISTS idx_files_checksum ON files(checksum)'); diff --git a/src/main/services/LibraryService.ts b/src/main/services/LibraryService.ts index 33fe30e..376caeb 100644 --- a/src/main/services/LibraryService.ts +++ b/src/main/services/LibraryService.ts @@ -453,7 +453,7 @@ export class LibraryService { path.basename(targetPath), path.basename(targetPath, path.extname(targetPath)) ); - this.waveformPreviewCache.delete(fileId); + this.clearWaveformCache(fileId); this.search.rebuildIndex(); return updated; } @@ -478,7 +478,7 @@ export class LibraryService { path.basename(targetPath), path.basename(targetPath, path.extname(targetPath)) ); - this.waveformPreviewCache.delete(fileId); + this.clearWaveformCache(fileId); this.search.rebuildIndex(); return updated; } @@ -501,8 +501,17 @@ export class LibraryService { * Generates a lightweight waveform preview suitable for list rendering. */ public async getWaveformPreview(fileId: number, pointCount = 160): Promise<{ samples: number[]; rms: number }> { + const startTime = performance.now(); const record = this.database.getFileById(fileId); const effectivePoints = Math.min(Math.max(pointCount ?? 160, 32), 16384); + + // Check database cache first + const dbCache = this.database.getWaveformCache(fileId, effectivePoints); + if (dbCache) { + return dbCache; + } + + // Check memory cache const cacheHit = this.waveformPreviewCache.get(fileId); if (cacheHit && cacheHit.modifiedAt === record.modifiedAt && cacheHit.pointCount === effectivePoints) { return { samples: cacheHit.samples, rms: cacheHit.rms }; @@ -511,6 +520,15 @@ export class LibraryService { try { const buffer = await fs.readFile(record.absolutePath); const wave = new WaveFile(buffer); + + // For large files, use optimized sampling instead of processing all samples + const fileSize = buffer.length; + const useFastSampling = fileSize > 10 * 1024 * 1024; // 10MB threshold + + if (useFastSampling) { + return await this.getWaveformPreviewFast(fileId, record, wave, effectivePoints, buffer, startTime); + } + const sampleBlock = wave.getSamples(false, Float64Array) as Float64Array | Float64Array[]; const channels = (Array.isArray(sampleBlock) ? sampleBlock : [sampleBlock]) as Float64Array[]; const firstChannel = channels[0]; @@ -523,6 +541,7 @@ export class LibraryService { samples: fallback, rms: 0 }); + this.database.setWaveformCache(fileId, effectivePoints, fallback, 0); return { samples: fallback, rms: 0 }; } @@ -561,6 +580,11 @@ export class LibraryService { samples, rms }); + this.database.setWaveformCache(fileId, effectivePoints, samples, rms); + + const totalTime = performance.now() - startTime; + const durationSec = record.durationMs ? (record.durationMs / 1000).toFixed(1) : 'unknown'; + console.log(`Waveform preview for ${record.fileName}: ${totalTime.toFixed(1)}ms (${durationSec}s audio, ${(buffer.length / 1024 / 1024).toFixed(1)}MB)`); return { samples, rms }; } catch (error) { @@ -572,16 +596,200 @@ export class LibraryService { samples: fallback, rms: 0 }); + this.database.setWaveformCache(fileId, effectivePoints, fallback, 0); return { samples: fallback, rms: 0 }; } } + /** + * Fast waveform preview generation for large files using sparse sampling. + * Reads raw audio data directly without extracting all samples. + */ + private async getWaveformPreviewFast( + fileId: number, + record: AudioFileSummary, + wave: WaveFile, + effectivePoints: number, + buffer: Buffer, + startTime: number + ): Promise<{ samples: number[]; rms: number }> { + // Parse WAV header to find data chunk + const fmt = wave.fmt as { numChannels: number; bitsPerSample: number }; + const numChannels = fmt.numChannels; + const bytesPerSample = Math.floor(fmt.bitsPerSample / 8); + const bytesPerFrame = numChannels * bytesPerSample; + + // Find the data chunk in the buffer + let dataOffset = 12; // Skip RIFF header + let dataSize = 0; + + while (dataOffset < buffer.length - 8) { + const chunkId = buffer.toString('ascii', dataOffset, dataOffset + 4); + const chunkSize = buffer.readUInt32LE(dataOffset + 4); + + if (chunkId === 'data') { + dataOffset += 8; // Skip chunk header + dataSize = chunkSize; + break; + } + + dataOffset += 8 + chunkSize; + if (chunkSize % 2 === 1) dataOffset++; // Word-aligned + } + + if (dataSize === 0) { + // Fallback if we can't find data chunk + const fallback = new Array(effectivePoints).fill(0); + this.waveformPreviewCache.set(fileId, { + modifiedAt: record.modifiedAt, + pointCount: effectivePoints, + samples: fallback, + rms: 0 + }); + return { samples: fallback, rms: 0 }; + } + + const totalFrames = Math.floor(dataSize / bytesPerFrame); + const framesPerPoint = Math.floor(totalFrames / effectivePoints); + + // Sample at most 100 frames per point for large files + const framesToSamplePerPoint = Math.min(framesPerPoint, 100); + const frameStep = Math.max(1, Math.floor(framesPerPoint / framesToSamplePerPoint)); + + const peaks: number[] = []; + let sumSquares = 0; + let sampleTotal = 0; + + // Determine max value for normalization based on bit depth + const maxValue = Math.pow(2, fmt.bitsPerSample - 1); + + for (let pointIndex = 0; pointIndex < effectivePoints; pointIndex++) { + const startFrame = pointIndex * framesPerPoint; + const endFrame = pointIndex === effectivePoints - 1 + ? totalFrames + : Math.min(totalFrames, startFrame + framesPerPoint); + + let blockPeak = 0; + + for (let frame = startFrame; frame < endFrame; frame += frameStep) { + const byteOffset = dataOffset + (frame * bytesPerFrame); + + // Read samples from all channels at this frame + for (let ch = 0; ch < numChannels; ch++) { + const sampleOffset = byteOffset + (ch * bytesPerSample); + + if (sampleOffset + bytesPerSample > buffer.length) break; + + // Read sample based on bit depth + let sampleValue = 0; + if (bytesPerSample === 2) { + sampleValue = buffer.readInt16LE(sampleOffset); + } else if (bytesPerSample === 3) { + // 24-bit + const byte1 = buffer.readUInt8(sampleOffset); + const byte2 = buffer.readUInt8(sampleOffset + 1); + const byte3 = buffer.readInt8(sampleOffset + 2); + sampleValue = (byte3 << 16) | (byte2 << 8) | byte1; + } else if (bytesPerSample === 4) { + sampleValue = buffer.readInt32LE(sampleOffset); + } else if (bytesPerSample === 1) { + sampleValue = buffer.readInt8(sampleOffset) << 8; // Convert 8-bit to 16-bit range + } + + const normalized = sampleValue / maxValue; + const clamped = Math.max(-1, Math.min(1, normalized)); + const magnitude = Math.abs(clamped); + + if (magnitude > blockPeak) { + blockPeak = magnitude; + } + + sumSquares += clamped * clamped; + sampleTotal++; + } + } + + peaks.push(Math.min(blockPeak, 1)); + } + + const samples = this.normaliseWaveformArray(peaks); + const rms = sampleTotal > 0 ? Math.sqrt(sumSquares / sampleTotal) : 0; + this.waveformPreviewCache.set(fileId, { + modifiedAt: record.modifiedAt, + pointCount: effectivePoints, + samples, + rms + }); + this.database.setWaveformCache(fileId, effectivePoints, samples, rms); + + const totalTime = performance.now() - startTime; + const durationSec = record.durationMs ? (record.durationMs / 1000).toFixed(1) : 'unknown'; + console.log(`Waveform preview for ${record.fileName}: ${totalTime.toFixed(1)}ms (${durationSec}s audio, ${(buffer.length / 1024 / 1024).toFixed(1)}MB) [FAST]`); + + return { samples, rms }; + } + + /** + * Returns full-resolution waveform samples for a specific time range. + * Used for high-detail editor rendering when zoomed in. + */ + public async getWaveformRange(fileId: number, startMs: number, endMs: number): Promise<{ samples: number[] }> { + const record = this.database.getFileById(fileId); + + try { + const buffer = await fs.readFile(record.absolutePath); + const wave = new WaveFile(buffer); + + // Get all samples for the entire file + const sampleBlock = wave.getSamples(false, Float64Array) as Float64Array | Float64Array[]; + const channels = (Array.isArray(sampleBlock) ? sampleBlock : [sampleBlock]) as Float64Array[]; + const firstChannel = channels[0]; + + if (!firstChannel || firstChannel.length === 0 || !record.durationMs || record.durationMs <= 0) { + return { samples: [] }; + } + + const amplitudeScale = Math.max(1, this.resolveWaveformAmplitudeScale(wave)); + const totalDurationMs = record.durationMs; + const samplesPerMs = firstChannel.length / totalDurationMs; + + // Calculate sample indices for the requested time range + const startSample = Math.floor(startMs * samplesPerMs); + const endSample = Math.ceil(endMs * samplesPerMs); + const clampedStart = Math.max(0, startSample); + const clampedEnd = Math.min(firstChannel.length, endSample); + + // Extract peaks for the range + const samples: number[] = []; + + for (let cursor = clampedStart; cursor < clampedEnd; cursor += 1) { + let peak = 0; + for (let channelIndex = 0; channelIndex < channels.length; channelIndex += 1) { + const channel = channels[channelIndex]; + const rawSample = channel?.[cursor] ?? 0; + const normalisedSample = rawSample / amplitudeScale; + const clampedSample = Math.max(-1, Math.min(1, normalisedSample)); + const magnitude = Math.abs(clampedSample); + if (magnitude > peak) { + peak = magnitude; + } + } + samples.push(Math.min(peak, 1)); + } + + return { samples: this.normaliseWaveformArray(samples) }; + } catch (error) { + console.warn('Failed to generate waveform range', { fileId, startMs, endMs, error }); + return { samples: [] }; + } + } + /** * Applies tag updates, optionally re-running the organize pipeline when categories are present. */ public async updateTagging(payload: TagUpdatePayload): Promise { const updated = this.tagService.applyTagging(payload.fileId, payload.tags, payload.categories); - this.waveformPreviewCache.delete(payload.fileId); + this.clearWaveformCache(payload.fileId); if (updated.categories.length > 0) { const metadataSnapshot = this.tagService.readMetadata(updated.absolutePath); @@ -731,7 +939,7 @@ export class LibraryService { } this.resetMetadataSuggestionsCache(); - this.waveformPreviewCache.delete(fileId); + this.clearWaveformCache(fileId); this.search.rebuildIndex(); return updatedRecord; } @@ -827,7 +1035,7 @@ export class LibraryService { } this.resetMetadataSuggestionsCache(); - this.waveformPreviewCache.delete(fileId); + this.clearWaveformCache(fileId); this.search.rebuildIndex(); return updated; } @@ -844,7 +1052,7 @@ export class LibraryService { console.error(`Failed to delete file ${record.absolutePath}:`, error); } this.database.deleteFile(fileId); - this.waveformPreviewCache.delete(fileId); + this.clearWaveformCache(fileId); } this.resetMetadataSuggestionsCache(); this.search.rebuildIndex(); @@ -1068,11 +1276,11 @@ export class LibraryService { this.updateMetadataSuggestionsCache(resolvedAuthor); } - this.waveformPreviewCache.delete(updatedRecord.id); + this.clearWaveformCache(updatedRecord.id); created.push(updatedRecord); } - this.waveformPreviewCache.delete(record.id); + this.clearWaveformCache(record.id); this.resetMetadataSuggestionsCache(); this.search.rebuildIndex(); @@ -1281,7 +1489,7 @@ export class LibraryService { this.updateMetadataSuggestionsCache(mergedMetadata.author); } - this.waveformPreviewCache.delete(fileId); + this.clearWaveformCache(fileId); this.resetMetadataSuggestionsCache(); } @@ -1654,6 +1862,14 @@ export class LibraryService { return candidate; } + /** + * Helper to clear both memory and database waveform cache. + */ + private clearWaveformCache(fileId: number): void { + this.waveformPreviewCache.delete(fileId); + this.database.clearWaveformCache(fileId); + } + /** * Extracts metadata from the WAV container, falling back to defaults when parsing fails. */ @@ -1834,23 +2050,30 @@ export class LibraryService { /** * Computes checksum from audio data only, excluding metadata chunks for stability. + * Uses streaming approach to handle large files efficiently. */ private async computeFileChecksum(filePath: string): Promise { try { + const stats = await fs.stat(filePath); + const fileSize = stats.size; + + // For files larger than 100MB, use chunked streaming to avoid memory issues + if (fileSize > 100 * 1024 * 1024) { + return await this.computeFileChecksumStreaming(filePath); + } + + // For smaller files, use the existing fast method const buffer = await fs.readFile(filePath); const wave = new WaveFile(buffer); - // Hash only the raw audio samples, not metadata or other chunks const hash = createHash('md5'); const samples = wave.getSamples(); if (Array.isArray(samples)) { - // Multi-channel: hash each channel for (const channel of samples) { hash.update(Buffer.from(channel.buffer)); } } else { - // Single channel hash.update(Buffer.from(samples.buffer)); } @@ -1865,6 +2088,74 @@ export class LibraryService { } } + /** + * Computes checksum using streaming for large files. + * Reads WAV header to locate data chunk, then streams only the audio data. + */ + private async computeFileChecksumStreaming(filePath: string): Promise { + return new Promise((resolve, reject) => { + const hash = createHash('md5'); + const stream = createReadStream(filePath, { highWaterMark: 64 * 1024 }); // 64KB chunks + + let headerProcessed = false; + let dataChunkStart = 0; + let dataChunkSize = 0; + let bytesRead = 0; + let buffer = Buffer.alloc(0); + + stream.on('data', (chunk: string | Buffer) => { + const bufferChunk = typeof chunk === 'string' ? Buffer.from(chunk) : chunk; + buffer = Buffer.concat([buffer, bufferChunk]); + bytesRead += bufferChunk.length; + + if (!headerProcessed && buffer.length >= 44) { + // Parse minimal WAV header to find data chunk + // RIFF header: "RIFF" (4) + fileSize (4) + "WAVE" (4) = 12 bytes + // fmt chunk: "fmt " (4) + size (4) + format data (usually 16) = 24+ bytes + // data chunk: "data" (4) + size (4) + audio data + + let offset = 12; // Skip RIFF header + while (offset + 8 <= buffer.length) { + const chunkId = buffer.toString('ascii', offset, offset + 4); + const chunkSize = buffer.readUInt32LE(offset + 4); + + if (chunkId === 'data') { + dataChunkStart = offset + 8; + dataChunkSize = chunkSize; + headerProcessed = true; + + // Hash any data chunk content we've already read + const availableData = buffer.length - dataChunkStart; + if (availableData > 0) { + const dataToHash = buffer.subarray(dataChunkStart, Math.min(buffer.length, dataChunkStart + dataChunkSize)); + hash.update(dataToHash); + } + + // Clear buffer to free memory + buffer = Buffer.alloc(0); + break; + } + + offset += 8 + chunkSize; + if (chunkSize % 2 === 1) offset++; // WAV chunks are word-aligned + } + } else if (headerProcessed) { + // We're in the data chunk, hash everything + hash.update(bufferChunk); + buffer = Buffer.alloc(0); // Clear buffer to free memory + } + }); + + stream.on('end', () => { + resolve(hash.digest('hex')); + }); + + stream.on('error', (error) => { + reject(error); + }); + }); + } + /** * Normalises file names, ensuring the .wav extension is present. */ diff --git a/src/preload/index.ts b/src/preload/index.ts index b143a10..cadc728 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -76,6 +76,9 @@ const api: RendererApi = { async getWaveformPreview(fileId: number, pointCount = 160): Promise<{ samples: number[]; rms: number }> { return ipcRenderer.invoke(IPC_CHANNELS.libraryWaveformPreview, fileId, pointCount); }, + async getWaveformRange(fileId: number, startMs: number, endMs: number): Promise<{ samples: number[] }> { + return ipcRenderer.invoke(IPC_CHANNELS.libraryWaveformRange, fileId, startMs, endMs); + }, async updateTagging(payload: TagUpdatePayload): Promise { return ipcRenderer.invoke(IPC_CHANNELS.tagsUpdate, payload); }, diff --git a/src/renderer/src/components/AudioPlayer.tsx b/src/renderer/src/components/AudioPlayer.tsx index 38b629c..5af5d53 100644 --- a/src/renderer/src/components/AudioPlayer.tsx +++ b/src/renderer/src/components/AudioPlayer.tsx @@ -64,7 +64,7 @@ export interface AudioPlayerProps { /** * Seamless audio player that integrates smoothly with the interface. */ -export function AudioPlayer({ snapshot }: AudioPlayerProps): JSX.Element { +export function AudioPlayer({ snapshot }: AudioPlayerProps) { const audioRef = useRef(null); const [isPlaying, setIsPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); diff --git a/src/renderer/src/components/FileList.tsx b/src/renderer/src/components/FileList.tsx index 1d140bc..e0e8cff 100644 --- a/src/renderer/src/components/FileList.tsx +++ b/src/renderer/src/components/FileList.tsx @@ -14,9 +14,10 @@ export interface FileListProps { /** * Vertical list of WAV files with highlighting for the active selection. */ -export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, searchValue, onSearchChange }: FileListProps): JSX.Element { +export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, searchValue, onSearchChange }: FileListProps) { const buttonRefs = useRef(new Map()); const waveformCacheRef = useRef>({}); + const waveformLoadedRef = useRef>(new Set()); const [, forceWaveformUpdate] = useReducer((value: number) => value + 1, 0); const dragGhostRef = useRef(null); const dragRotation = useRef(0); @@ -57,34 +58,63 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea } let cancelled = false; - const loadPreviews = async () => { - for (const file of files) { - if (waveformCacheRef.current[file.id]) { - continue; - } - try { - const preview = await window.api.getWaveformPreview(file.id, WAVEFORM_POINT_COUNT); - if (cancelled) { - return; + const loadingSet = new Set(); + + // Create intersection observer to load waveforms only for visible items + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + const button = entry.target as HTMLElement; + const fileId = Number.parseInt(button.dataset.fileId ?? '0', 10); + + if (!fileId || waveformCacheRef.current[fileId] || loadingSet.has(fileId)) { + continue; + } + + const file = files.find((f) => f.id === fileId); + if (!file) continue; + + loadingSet.add(fileId); + + void (async () => { + try { + const preview = await window.api.getWaveformPreview(fileId, WAVEFORM_POINT_COUNT); + if (cancelled) return; + + const seed = file.checksum ?? file.absolutePath; + waveformCacheRef.current[fileId] = buildWaveformVisual(seed, preview.samples, preview.rms); + waveformLoadedRef.current.add(fileId); + forceWaveformUpdate(); + } catch (error) { + if (cancelled) return; + console.error(`Failed to load waveform preview for file ${fileId} (${file.fileName}):`, error); + const seed = file.checksum ?? file.absolutePath; + waveformCacheRef.current[fileId] = buildWaveformVisual(seed, null, null); + waveformLoadedRef.current.add(fileId); + forceWaveformUpdate(); + } finally { + loadingSet.delete(fileId); + } + })(); } - const seed = file.checksum ?? file.absolutePath; - waveformCacheRef.current[file.id] = buildWaveformVisual(seed, preview.samples, preview.rms); - forceWaveformUpdate(); - } catch (error) { - if (cancelled) { - return; - } - console.error(`Failed to load waveform preview for file ${file.id} (${file.fileName}):`, error); - const seed = file.checksum ?? file.absolutePath; - waveformCacheRef.current[file.id] = buildWaveformVisual(seed, null, null); - forceWaveformUpdate(); } + }, + { + root: null, + rootMargin: '200px', // Load waveforms 200px before they come into view + threshold: 0 } - }; + ); + + // Observe all file buttons + for (const button of buttonRefs.current.values()) { + observer.observe(button); + } - void loadPreviews(); return () => { cancelled = true; + observer.disconnect(); }; }, [files]); @@ -138,7 +168,8 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea const primaryLabel = customLabel && customLabel.length > 0 ? customLabel : file.displayName; const seed = file.checksum ?? file.absolutePath; const gradientId = `waveGradient-${file.id}`; - const wave = waveformCacheRef.current[file.id] ?? buildWaveformVisual(seed, null, null); + const wave = waveformCacheRef.current[file.id] ?? buildWaveformVisual(seed, null, null); + const hasLoadedWaveform = waveformLoadedRef.current.has(file.id); return (