6 Commits

12 changed files with 529 additions and 62 deletions

View File

@@ -4,7 +4,6 @@ on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
build-windows:

View File

@@ -1,7 +1,7 @@
# AudioSort
[![Build/release](https://github.com/litruv/AudioSort/actions/workflows/build.yml/badge.svg)](https://github.com/litruv/AudioSort/actions/workflows/build.yml)
[![Build All Platforms](https://github.com/litruv/AudioSort/actions/workflows/build-all.yml/badge.svg)](https://github.com/litruv/AudioSort/actions/workflows/build-all.yml)
A desktop application for organizing, tagging, and managing WAV audio files with Universal Category System (UCS) support, featuring advanced waveform editing and audio splitting capabilities.
A cross-platform desktop application for organizing, tagging, and managing WAV audio files with Universal Category System (UCS) support, featuring advanced waveform editing and audio splitting capabilities.
![AudioSort Interface](repoimages/interface.png)
@@ -49,9 +49,29 @@ A desktop application for organizing, tagging, and managing WAV audio files with
## Installation
### From Release
### Windows
Download the latest `AudioSort Setup.exe` from the [Releases](../../releases) page and run the installer.
### Linux
#### AppImage (Universal)
```bash
# Download from Releases page
chmod +x AudioSort-*.AppImage
./AudioSort-*.AppImage
```
#### Debian/Ubuntu (.deb)
```bash
sudo dpkg -i audio-sort_*.deb
sudo apt-get install -f # Install dependencies if needed
```
#### Fedora/RHEL (.rpm)
```bash
sudo dnf install ./audio-sort-*.rpm
```
### From Source
#### Prerequisites
@@ -126,23 +146,27 @@ AudioSort/
### Scripts
```bash
npm run dev # Start development server with hot reload
npm run build # Build renderer and main process
npm run lint # Type-check with TypeScript
npm run pack # Package without installer
npm run dist # Create distributable
npm run dist:win # Create Windows installer
npm run dev # Start development server with hot reload
npm run build # Build renderer and main process
npm run lint # Type-check with TypeScript
npm run pack # Package without installer
npm run dist # Create distributable
npm run dist:win # Create Windows installer
npm run dist:linux # Create Linux packages (AppImage, deb, rpm)
```
## Configuration
## Linux Distribution
### Build Configuration
Edit `package.json` `build` section to customize:
- Application name and ID
- Icon and branding
- Installer options
- File associations
For detailed information about building and distributing on Linux, see [LINUX_BUILD.md](LINUX_BUILD.md).
Supported formats:
- **AppImage** - Universal, runs on any distribution
- **DEB** - Debian, Ubuntu, and derivatives
- **RPM** - Fedora, RHEL, CentOS, openSUSE
## Configuration
### Database Location
User data is stored in:
- **Windows**: `%APPDATA%/AudioSort/`
- **Linux**: `~/.config/AudioSort/`

View File

@@ -1,6 +1,6 @@
{
"name": "audio-sort",
"version": "0.5.2",
"version": "0.5.10",
"description": "Electron audio sorting application with fuzzy search, tagging, library management, and advanced waveform editing with audio splitting capabilities for WAV files.",
"author": {
"name": "litruv",

View File

@@ -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)
);

View File

@@ -1,6 +1,11 @@
import { app } from 'electron';
import { MainApp } from './MainApp';
// Disable GPU acceleration on Linux to avoid graphics driver issues
if (process.platform === 'linux') {
app.disableHardwareAcceleration();
}
const mainApp = new MainApp();
app.whenReady()

View File

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

View File

@@ -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<AudioFileSummary> {
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<string | null> {
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<string> {
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.
*/

View File

@@ -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<AudioFileSummary> {
return ipcRenderer.invoke(IPC_CHANNELS.tagsUpdate, payload);
},

View File

@@ -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<HTMLAudioElement | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);

View File

@@ -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<number, HTMLButtonElement>());
const waveformCacheRef = useRef<Record<number, WaveformVisual>>({});
const waveformLoadedRef = useRef<Set<number>>(new Set());
const [, forceWaveformUpdate] = useReducer((value: number) => value + 1, 0);
const dragGhostRef = useRef<HTMLElement | null>(null);
const dragRotation = useRef<number>(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<number>();
// 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 (
<button
key={file.id}
@@ -288,6 +319,12 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea
viewBox={`0 0 ${WAVEFORM_WIDTH} ${WAVEFORM_HEIGHT}`}
preserveAspectRatio="none"
aria-hidden="true"
style={{
transform: hasLoadedWaveform ? 'scaleY(1)' : 'scaleY(0)',
transformOrigin: 'center',
transition: 'transform 0.75s cubic-bezier(0.34, 1.56, 0.64, 1)',
opacity: hasLoadedWaveform ? 1 : 0
}}
>
<defs>
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="100%">

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
export interface WaveformProps {
audioUrl: string | null;
@@ -9,25 +9,43 @@ export interface WaveformProps {
/**
* Renders an audio waveform visualization that serves as the background for the progress bar.
* Skips waveform generation for very long files to avoid UI lag.
*/
export function Waveform({ audioUrl, currentTime, duration, className = '' }: WaveformProps): JSX.Element {
export function Waveform({ audioUrl, currentTime, duration, className = '' }: WaveformProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const waveformDataRef = useRef<number[] | null>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (!audioUrl) {
return;
}
let cancelled = false;
const loadWaveform = async () => {
try {
const audioContext = new AudioContext();
setIsLoading(true);
const startTime = performance.now();
const response = await fetch(audioUrl);
const arrayBuffer = await response.arrayBuffer();
if (cancelled) return;
const audioContext = new AudioContext();
const decodeStart = performance.now();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
const decodeTime = performance.now() - decodeStart;
if (cancelled) {
await audioContext.close();
return;
}
const processStart = performance.now();
const rawData = audioBuffer.getChannelData(0);
const samples = 500;
const samples = 100; // Reduced from 500 to 100 for faster generation
const blockSize = Math.floor(rawData.length / samples);
const filteredData: number[] = [];
@@ -40,18 +58,36 @@ export function Waveform({ audioUrl, currentTime, duration, className = '' }: Wa
filteredData.push(sum / blockSize);
}
if (cancelled) {
await audioContext.close();
return;
}
const multiplier = Math.max(...filteredData) ** -1;
waveformDataRef.current = filteredData.map((n) => n * multiplier);
drawWaveform();
await audioContext.close();
const totalTime = performance.now() - startTime;
const processTime = performance.now() - processStart;
console.log(`Waveform generation: total=${totalTime.toFixed(1)}ms, decode=${decodeTime.toFixed(1)}ms, process=${processTime.toFixed(1)}ms, duration=${(duration / 1000).toFixed(1)}s`);
setIsLoading(false);
} catch (error) {
console.warn('Failed to generate waveform', error);
if (!cancelled) {
console.warn('Failed to generate waveform', error);
setIsLoading(false);
}
}
};
void loadWaveform();
}, [audioUrl]);
return () => {
cancelled = true;
};
}, [audioUrl, duration]);
useEffect(() => {
drawWaveform();
@@ -100,7 +136,23 @@ export function Waveform({ audioUrl, currentTime, duration, className = '' }: Wa
}
};
return <canvas ref={canvasRef} className={className} style={{ width: '100%', height: '100%' }} />;
return (
<>
<canvas ref={canvasRef} className={className} style={{ width: '100%', height: '100%' }} />
{isLoading && (
<div style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
fontSize: '10px',
color: 'rgba(255, 255, 255, 0.5)'
}}>
Loading waveform...
</div>
)}
</>
);
}
export default Waveform;

View File

@@ -21,6 +21,7 @@ export const IPC_CHANNELS = {
libraryMetadataSuggestions: 'library:metadata-suggestions',
libraryUpdateMetadata: 'library:update-metadata',
libraryWaveformPreview: 'library:waveform-preview',
libraryWaveformRange: 'library:waveform-range',
libraryImport: 'library:import',
tagsUpdate: 'tags:update',
categoriesList: 'categories:list',
@@ -74,6 +75,8 @@ export interface RendererApi {
getAudioBuffer(fileId: number): Promise<import('./models').AudioBufferPayload>;
/** Returns a lightweight waveform preview for rendering list backgrounds. */
getWaveformPreview(fileId: number, pointCount?: number): Promise<{ samples: number[]; rms: number }>;
/** Returns full-resolution waveform samples for a specific time range (for zoomed editor). */
getWaveformRange(fileId: number, startMs: number, endMs: number): Promise<{ samples: number[] }>;
/** Updates UCS categories for a file (free-form tags are preserved unless explicitly provided). */
updateTagging(payload: import('./models').TagUpdatePayload): Promise<import('./models').AudioFileSummary>;
/** Returns the catalog of UCS categories. */