19 Commits
v0.5.3 ... main

Author SHA1 Message Date
88d87b4aa1 Remove workflow_dispatch and set version to 0.5.10 2025-11-14 10:28:04 +11:00
22e1d7eab9 Bump version to 0.5.11 2025-11-14 10:13:31 +11:00
5f7aff8381 Bump version to 0.5.3 2025-11-14 10:08:23 +11:00
84cc921c70 feat: add waveform caching and range retrieval functionality 2025-11-14 10:03:31 +11:00
d3e596e9a9 fix: update README for improved Linux installation instructions and clarify application description 2025-11-12 08:19:40 +11:00
140cc18a31 fix: disable GPU acceleration on Linux to avoid graphics driver issues 2025-11-12 07:39:03 +11:00
c6df01061a revert: restore RPM target 2025-11-12 07:13:00 +11:00
720fdf8946 fix: remove RPM target (requires rpmbuild on runner) 2025-11-12 07:12:25 +11:00
e3f4bfc7e2 fix: use OS labels to distinguish self-hosted runners 2025-11-12 07:07:03 +11:00
4d3d616574 fix: add GH_TOKEN to build steps for electron-builder 2025-11-12 07:04:52 +11:00
93d0da3496 chore: use self-hosted runner for Linux builds 2025-11-12 07:01:51 +11:00
761b1574e7 fix: remove snap target to avoid publishing issues 2025-11-12 06:56:30 +11:00
953c22bec3 chore: use self-hosted runner for Windows builds 2025-11-12 06:54:40 +11:00
acf4b8d8b7 chore: add author website 2025-11-12 06:43:48 +11:00
fe38e7e8f2 fix: add author email and disable snap publishing 2025-11-12 06:42:57 +11:00
e16abd7370 fix: add PNG icon for Linux builds 2025-11-12 06:38:44 +11:00
9ade1cc7dd fix: only build on tags, not every push 2025-11-12 06:37:05 +11:00
7ecb65153c fix: use icon.ico for Linux builds 2025-11-12 06:35:18 +11:00
65dadc9e3f chore: consolidate to single build workflow 2025-11-12 06:34:22 +11:00
15 changed files with 541 additions and 175 deletions

View File

@@ -2,16 +2,12 @@ name: Build All Platforms
on: on:
push: push:
branches: [main]
tags: tags:
- 'v*' - 'v*'
pull_request:
branches: [main]
workflow_dispatch:
jobs: jobs:
build-windows: build-windows:
runs-on: windows-latest runs-on: [self-hosted, windows]
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -26,6 +22,8 @@ jobs:
- name: Build Windows - name: Build Windows
run: npm run dist:win run: npm run dist:win
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload Windows artifacts - name: Upload Windows artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -34,7 +32,7 @@ jobs:
path: release/*.exe path: release/*.exe
build-linux: build-linux:
runs-on: ubuntu-latest runs-on: [self-hosted, linux]
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -49,6 +47,8 @@ jobs:
- name: Build Linux - name: Build Linux
run: npm run dist:linux run: npm run dist:linux
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload Linux artifacts - name: Upload Linux artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -58,7 +58,6 @@ jobs:
release/*.AppImage release/*.AppImage
release/*.deb release/*.deb
release/*.rpm release/*.rpm
release/*.snap
release: release:
needs: [build-windows, build-linux] needs: [build-windows, build-linux]
@@ -83,7 +82,6 @@ jobs:
*.AppImage *.AppImage
*.deb *.deb
*.rpm *.rpm
*.snap
draft: false draft: false
prerelease: false prerelease: false
env: env:

View File

@@ -1,65 +0,0 @@
name: Build Linux
on:
push:
branches: [main]
tags:
- 'v*'
pull_request:
branches: [main]
workflow_dispatch:
jobs:
build-linux:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm install
- name: Build Linux packages
run: npm run dist:linux
- name: Upload AppImage
uses: actions/upload-artifact@v4
with:
name: linux-appimage
path: release/*.AppImage
- name: Upload DEB
uses: actions/upload-artifact@v4
with:
name: linux-deb
path: release/*.deb
- name: Upload RPM
uses: actions/upload-artifact@v4
with:
name: linux-rpm
path: release/*.rpm
- name: Upload Snap
uses: actions/upload-artifact@v4
with:
name: linux-snap
path: release/*.snap
- name: Release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
files: |
release/*.AppImage
release/*.deb
release/*.rpm
release/*.snap
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,29 +0,0 @@
name: Build/release
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: windows-latest
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Install Node.js and NPM
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: npm install
- name: Build/release Electron app
uses: samuelmeuli/action-electron-builder@v1
with:
github_token: ${{ secrets.github_token }}
release: ${{ startsWith(github.ref, 'refs/tags/v') }}
args: --win

View File

@@ -1,7 +1,7 @@
# AudioSort # 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) ![AudioSort Interface](repoimages/interface.png)
@@ -49,9 +49,29 @@ A desktop application for organizing, tagging, and managing WAV audio files with
## Installation ## Installation
### From Release ### Windows
Download the latest `AudioSort Setup.exe` from the [Releases](../../releases) page and run the installer. 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 ### From Source
#### Prerequisites #### Prerequisites
@@ -126,23 +146,27 @@ AudioSort/
### Scripts ### Scripts
```bash ```bash
npm run dev # Start development server with hot reload npm run dev # Start development server with hot reload
npm run build # Build renderer and main process npm run build # Build renderer and main process
npm run lint # Type-check with TypeScript npm run lint # Type-check with TypeScript
npm run pack # Package without installer npm run pack # Package without installer
npm run dist # Create distributable npm run dist # Create distributable
npm run dist:win # Create Windows installer npm run dist:win # Create Windows installer
npm run dist:linux # Create Linux packages (AppImage, deb, rpm)
``` ```
## Configuration ## Linux Distribution
### Build Configuration For detailed information about building and distributing on Linux, see [LINUX_BUILD.md](LINUX_BUILD.md).
Edit `package.json` `build` section to customize:
- Application name and ID Supported formats:
- Icon and branding - **AppImage** - Universal, runs on any distribution
- Installer options - **DEB** - Debian, Ubuntu, and derivatives
- File associations - **RPM** - Fedora, RHEL, CentOS, openSUSE
## Configuration
### Database Location ### Database Location
User data is stored in: User data is stored in:
- **Windows**: `%APPDATA%/AudioSort/` - **Windows**: `%APPDATA%/AudioSort/`
- **Linux**: `~/.config/AudioSort/`

BIN
build/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

View File

@@ -1,8 +1,12 @@
{ {
"name": "audio-sort", "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.", "description": "Electron audio sorting application with fuzzy search, tagging, library management, and advanced waveform editing with audio splitting capabilities for WAV files.",
"author": "litruv", "author": {
"name": "litruv",
"email": "litruv@example.com",
"url": "https://lit.ruv.wtf"
},
"main": "dist/main/main/index.js", "main": "dist/main/main/index.js",
"type": "commonjs", "type": "commonjs",
"scripts": { "scripts": {
@@ -72,8 +76,7 @@
"target": [ "target": [
"AppImage", "AppImage",
"deb", "deb",
"rpm", "rpm"
"snap"
], ],
"icon": "build/icon.png", "icon": "build/icon.png",
"category": "AudioVideo", "category": "AudioVideo",
@@ -83,15 +86,6 @@
"audio/x-wav" "audio/x-wav"
] ]
}, },
"snap": {
"summary": "Audio sorting and management application",
"plugs": [
"default",
"audio-playback",
"home",
"removable-media"
]
},
"extraResources": [ "extraResources": [
{ {
"from": "data/UCS.csv", "from": "data/UCS.csv",

View File

@@ -161,6 +161,7 @@ export class MainApp {
ipcMain.removeHandler(IPC_CHANNELS.libraryUpdateMetadata); ipcMain.removeHandler(IPC_CHANNELS.libraryUpdateMetadata);
ipcMain.removeHandler(IPC_CHANNELS.librarySplit); ipcMain.removeHandler(IPC_CHANNELS.librarySplit);
ipcMain.removeHandler(IPC_CHANNELS.libraryWaveformPreview); ipcMain.removeHandler(IPC_CHANNELS.libraryWaveformPreview);
ipcMain.removeHandler(IPC_CHANNELS.libraryWaveformRange);
ipcMain.removeHandler(IPC_CHANNELS.libraryImport); ipcMain.removeHandler(IPC_CHANNELS.libraryImport);
ipcMain.removeHandler(IPC_CHANNELS.tagsUpdate); ipcMain.removeHandler(IPC_CHANNELS.tagsUpdate);
ipcMain.removeHandler(IPC_CHANNELS.categoriesList); ipcMain.removeHandler(IPC_CHANNELS.categoriesList);
@@ -313,6 +314,12 @@ export class MainApp {
this.requireLibrary().getWaveformPreview(fileId, pointCount) 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) => ipcMain.handle(IPC_CHANNELS.tagsUpdate, async (_event: IpcMainInvokeEvent, payload: TagUpdatePayload) =>
this.requireLibrary().updateTagging(payload) this.requireLibrary().updateTagging(payload)
); );

View File

@@ -1,6 +1,11 @@
import { app } from 'electron'; import { app } from 'electron';
import { MainApp } from './MainApp'; import { MainApp } from './MainApp';
// Disable GPU acceleration on Linux to avoid graphics driver issues
if (process.platform === 'linux') {
app.disableHardwareAcceleration();
}
const mainApp = new MainApp(); const mainApp = new MainApp();
app.whenReady() 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. * 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', 'checksum', 'TEXT');
this.addColumnIfMissing(connection, 'files', 'custom_name', 'TEXT'); this.addColumnIfMissing(connection, 'files', 'custom_name', 'TEXT');
this.addColumnIfMissing(connection, 'files', 'parent_file_id', 'INTEGER'); this.addColumnIfMissing(connection, 'files', 'parent_file_id', 'INTEGER');
this.addColumnIfMissing(connection, 'files', 'waveform_cache', 'TEXT');
// Create checksum index after ensuring column exists // Create checksum index after ensuring column exists
connection.exec('CREATE INDEX IF NOT EXISTS idx_files_checksum ON files(checksum)'); 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.basename(targetPath, path.extname(targetPath)) path.basename(targetPath, path.extname(targetPath))
); );
this.waveformPreviewCache.delete(fileId); this.clearWaveformCache(fileId);
this.search.rebuildIndex(); this.search.rebuildIndex();
return updated; return updated;
} }
@@ -478,7 +478,7 @@ export class LibraryService {
path.basename(targetPath), path.basename(targetPath),
path.basename(targetPath, path.extname(targetPath)) path.basename(targetPath, path.extname(targetPath))
); );
this.waveformPreviewCache.delete(fileId); this.clearWaveformCache(fileId);
this.search.rebuildIndex(); this.search.rebuildIndex();
return updated; return updated;
} }
@@ -501,8 +501,17 @@ export class LibraryService {
* Generates a lightweight waveform preview suitable for list rendering. * Generates a lightweight waveform preview suitable for list rendering.
*/ */
public async getWaveformPreview(fileId: number, pointCount = 160): Promise<{ samples: number[]; rms: number }> { public async getWaveformPreview(fileId: number, pointCount = 160): Promise<{ samples: number[]; rms: number }> {
const startTime = performance.now();
const record = this.database.getFileById(fileId); const record = this.database.getFileById(fileId);
const effectivePoints = Math.min(Math.max(pointCount ?? 160, 32), 16384); 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); const cacheHit = this.waveformPreviewCache.get(fileId);
if (cacheHit && cacheHit.modifiedAt === record.modifiedAt && cacheHit.pointCount === effectivePoints) { if (cacheHit && cacheHit.modifiedAt === record.modifiedAt && cacheHit.pointCount === effectivePoints) {
return { samples: cacheHit.samples, rms: cacheHit.rms }; return { samples: cacheHit.samples, rms: cacheHit.rms };
@@ -511,6 +520,15 @@ export class LibraryService {
try { try {
const buffer = await fs.readFile(record.absolutePath); const buffer = await fs.readFile(record.absolutePath);
const wave = new WaveFile(buffer); 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 sampleBlock = wave.getSamples(false, Float64Array) as Float64Array | Float64Array[];
const channels = (Array.isArray(sampleBlock) ? sampleBlock : [sampleBlock]) as Float64Array[]; const channels = (Array.isArray(sampleBlock) ? sampleBlock : [sampleBlock]) as Float64Array[];
const firstChannel = channels[0]; const firstChannel = channels[0];
@@ -523,6 +541,7 @@ export class LibraryService {
samples: fallback, samples: fallback,
rms: 0 rms: 0
}); });
this.database.setWaveformCache(fileId, effectivePoints, fallback, 0);
return { samples: fallback, rms: 0 }; return { samples: fallback, rms: 0 };
} }
@@ -561,6 +580,11 @@ export class LibraryService {
samples, samples,
rms 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 }; return { samples, rms };
} catch (error) { } catch (error) {
@@ -572,16 +596,200 @@ export class LibraryService {
samples: fallback, samples: fallback,
rms: 0 rms: 0
}); });
this.database.setWaveformCache(fileId, effectivePoints, fallback, 0);
return { samples: fallback, rms: 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. * Applies tag updates, optionally re-running the organize pipeline when categories are present.
*/ */
public async updateTagging(payload: TagUpdatePayload): Promise<AudioFileSummary> { public async updateTagging(payload: TagUpdatePayload): Promise<AudioFileSummary> {
const updated = this.tagService.applyTagging(payload.fileId, payload.tags, payload.categories); 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) { if (updated.categories.length > 0) {
const metadataSnapshot = this.tagService.readMetadata(updated.absolutePath); const metadataSnapshot = this.tagService.readMetadata(updated.absolutePath);
@@ -731,7 +939,7 @@ export class LibraryService {
} }
this.resetMetadataSuggestionsCache(); this.resetMetadataSuggestionsCache();
this.waveformPreviewCache.delete(fileId); this.clearWaveformCache(fileId);
this.search.rebuildIndex(); this.search.rebuildIndex();
return updatedRecord; return updatedRecord;
} }
@@ -827,7 +1035,7 @@ export class LibraryService {
} }
this.resetMetadataSuggestionsCache(); this.resetMetadataSuggestionsCache();
this.waveformPreviewCache.delete(fileId); this.clearWaveformCache(fileId);
this.search.rebuildIndex(); this.search.rebuildIndex();
return updated; return updated;
} }
@@ -844,7 +1052,7 @@ export class LibraryService {
console.error(`Failed to delete file ${record.absolutePath}:`, error); console.error(`Failed to delete file ${record.absolutePath}:`, error);
} }
this.database.deleteFile(fileId); this.database.deleteFile(fileId);
this.waveformPreviewCache.delete(fileId); this.clearWaveformCache(fileId);
} }
this.resetMetadataSuggestionsCache(); this.resetMetadataSuggestionsCache();
this.search.rebuildIndex(); this.search.rebuildIndex();
@@ -1068,11 +1276,11 @@ export class LibraryService {
this.updateMetadataSuggestionsCache(resolvedAuthor); this.updateMetadataSuggestionsCache(resolvedAuthor);
} }
this.waveformPreviewCache.delete(updatedRecord.id); this.clearWaveformCache(updatedRecord.id);
created.push(updatedRecord); created.push(updatedRecord);
} }
this.waveformPreviewCache.delete(record.id); this.clearWaveformCache(record.id);
this.resetMetadataSuggestionsCache(); this.resetMetadataSuggestionsCache();
this.search.rebuildIndex(); this.search.rebuildIndex();
@@ -1281,7 +1489,7 @@ export class LibraryService {
this.updateMetadataSuggestionsCache(mergedMetadata.author); this.updateMetadataSuggestionsCache(mergedMetadata.author);
} }
this.waveformPreviewCache.delete(fileId); this.clearWaveformCache(fileId);
this.resetMetadataSuggestionsCache(); this.resetMetadataSuggestionsCache();
} }
@@ -1654,6 +1862,14 @@ export class LibraryService {
return candidate; 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. * 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. * 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> { private async computeFileChecksum(filePath: string): Promise<string | null> {
try { 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 buffer = await fs.readFile(filePath);
const wave = new WaveFile(buffer); const wave = new WaveFile(buffer);
// Hash only the raw audio samples, not metadata or other chunks
const hash = createHash('md5'); const hash = createHash('md5');
const samples = wave.getSamples(); const samples = wave.getSamples();
if (Array.isArray(samples)) { if (Array.isArray(samples)) {
// Multi-channel: hash each channel
for (const channel of samples) { for (const channel of samples) {
hash.update(Buffer.from(channel.buffer)); hash.update(Buffer.from(channel.buffer));
} }
} else { } else {
// Single channel
hash.update(Buffer.from(samples.buffer)); 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. * 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 }> { async getWaveformPreview(fileId: number, pointCount = 160): Promise<{ samples: number[]; rms: number }> {
return ipcRenderer.invoke(IPC_CHANNELS.libraryWaveformPreview, fileId, pointCount); 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> { async updateTagging(payload: TagUpdatePayload): Promise<AudioFileSummary> {
return ipcRenderer.invoke(IPC_CHANNELS.tagsUpdate, payload); 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. * 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 audioRef = useRef<HTMLAudioElement | null>(null);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0); 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. * 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 buttonRefs = useRef(new Map<number, HTMLButtonElement>());
const waveformCacheRef = useRef<Record<number, WaveformVisual>>({}); const waveformCacheRef = useRef<Record<number, WaveformVisual>>({});
const waveformLoadedRef = useRef<Set<number>>(new Set());
const [, forceWaveformUpdate] = useReducer((value: number) => value + 1, 0); const [, forceWaveformUpdate] = useReducer((value: number) => value + 1, 0);
const dragGhostRef = useRef<HTMLElement | null>(null); const dragGhostRef = useRef<HTMLElement | null>(null);
const dragRotation = useRef<number>(0); const dragRotation = useRef<number>(0);
@@ -57,34 +58,63 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea
} }
let cancelled = false; let cancelled = false;
const loadPreviews = async () => { const loadingSet = new Set<number>();
for (const file of files) {
if (waveformCacheRef.current[file.id]) { // Create intersection observer to load waveforms only for visible items
continue; const observer = new IntersectionObserver(
} (entries) => {
try { for (const entry of entries) {
const preview = await window.api.getWaveformPreview(file.id, WAVEFORM_POINT_COUNT); if (entry.isIntersecting) {
if (cancelled) { const button = entry.target as HTMLElement;
return; 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 () => { return () => {
cancelled = true; cancelled = true;
observer.disconnect();
}; };
}, [files]); }, [files]);
@@ -138,7 +168,8 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea
const primaryLabel = customLabel && customLabel.length > 0 ? customLabel : file.displayName; const primaryLabel = customLabel && customLabel.length > 0 ? customLabel : file.displayName;
const seed = file.checksum ?? file.absolutePath; const seed = file.checksum ?? file.absolutePath;
const gradientId = `waveGradient-${file.id}`; 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 ( return (
<button <button
key={file.id} key={file.id}
@@ -288,6 +319,12 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea
viewBox={`0 0 ${WAVEFORM_WIDTH} ${WAVEFORM_HEIGHT}`} viewBox={`0 0 ${WAVEFORM_WIDTH} ${WAVEFORM_HEIGHT}`}
preserveAspectRatio="none" preserveAspectRatio="none"
aria-hidden="true" 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> <defs>
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="100%"> <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 { export interface WaveformProps {
audioUrl: string | null; audioUrl: string | null;
@@ -9,25 +9,43 @@ export interface WaveformProps {
/** /**
* Renders an audio waveform visualization that serves as the background for the progress bar. * 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 canvasRef = useRef<HTMLCanvasElement | null>(null);
const waveformDataRef = useRef<number[] | null>(null); const waveformDataRef = useRef<number[] | null>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => { useEffect(() => {
if (!audioUrl) { if (!audioUrl) {
return; return;
} }
let cancelled = false;
const loadWaveform = async () => { const loadWaveform = async () => {
try { try {
const audioContext = new AudioContext(); setIsLoading(true);
const startTime = performance.now();
const response = await fetch(audioUrl); const response = await fetch(audioUrl);
const arrayBuffer = await response.arrayBuffer(); const arrayBuffer = await response.arrayBuffer();
if (cancelled) return;
const audioContext = new AudioContext();
const decodeStart = performance.now();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); 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 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 blockSize = Math.floor(rawData.length / samples);
const filteredData: number[] = []; const filteredData: number[] = [];
@@ -40,18 +58,36 @@ export function Waveform({ audioUrl, currentTime, duration, className = '' }: Wa
filteredData.push(sum / blockSize); filteredData.push(sum / blockSize);
} }
if (cancelled) {
await audioContext.close();
return;
}
const multiplier = Math.max(...filteredData) ** -1; const multiplier = Math.max(...filteredData) ** -1;
waveformDataRef.current = filteredData.map((n) => n * multiplier); waveformDataRef.current = filteredData.map((n) => n * multiplier);
drawWaveform(); drawWaveform();
await audioContext.close(); 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) { } catch (error) {
console.warn('Failed to generate waveform', error); if (!cancelled) {
console.warn('Failed to generate waveform', error);
setIsLoading(false);
}
} }
}; };
void loadWaveform(); void loadWaveform();
}, [audioUrl]);
return () => {
cancelled = true;
};
}, [audioUrl, duration]);
useEffect(() => { useEffect(() => {
drawWaveform(); 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; export default Waveform;

View File

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