mirror of
https://github.com/litruv/AudioSort.git
synced 2026-07-24 02:36:01 +10:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 88d87b4aa1 | |||
| 22e1d7eab9 | |||
| 5f7aff8381 | |||
| 84cc921c70 | |||
| d3e596e9a9 | |||
| 140cc18a31 | |||
| c6df01061a | |||
| 720fdf8946 | |||
| e3f4bfc7e2 | |||
| 4d3d616574 | |||
| 93d0da3496 | |||
| 761b1574e7 | |||
| 953c22bec3 | |||
| acf4b8d8b7 | |||
| fe38e7e8f2 | |||
| e16abd7370 | |||
| 9ade1cc7dd |
14
.github/workflows/build-all.yml
vendored
14
.github/workflows/build-all.yml
vendored
@@ -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:
|
||||||
|
|||||||
44
README.md
44
README.md
@@ -1,7 +1,7 @@
|
|||||||
# AudioSort
|
# AudioSort
|
||||||
[](https://github.com/litruv/AudioSort/actions/workflows/build.yml)
|
[](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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -132,17 +152,21 @@ 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
BIN
build/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 190 KiB |
22
package.json
22
package.json
@@ -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,10 +76,9 @@
|
|||||||
"target": [
|
"target": [
|
||||||
"AppImage",
|
"AppImage",
|
||||||
"deb",
|
"deb",
|
||||||
"rpm",
|
"rpm"
|
||||||
"snap"
|
|
||||||
],
|
],
|
||||||
"icon": "build/icon.ico",
|
"icon": "build/icon.png",
|
||||||
"category": "AudioVideo",
|
"category": "AudioVideo",
|
||||||
"description": "Audio sorting and management application",
|
"description": "Audio sorting and management application",
|
||||||
"mimeTypes": [
|
"mimeTypes": [
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)');
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const file = files.find((f) => f.id === fileId);
|
||||||
|
if (!file) continue;
|
||||||
|
|
||||||
|
loadingSet.add(fileId);
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const preview = await window.api.getWaveformPreview(file.id, WAVEFORM_POINT_COUNT);
|
const preview = await window.api.getWaveformPreview(fileId, WAVEFORM_POINT_COUNT);
|
||||||
if (cancelled) {
|
if (cancelled) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
const seed = file.checksum ?? file.absolutePath;
|
const seed = file.checksum ?? file.absolutePath;
|
||||||
waveformCacheRef.current[file.id] = buildWaveformVisual(seed, preview.samples, preview.rms);
|
waveformCacheRef.current[fileId] = buildWaveformVisual(seed, preview.samples, preview.rms);
|
||||||
|
waveformLoadedRef.current.add(fileId);
|
||||||
forceWaveformUpdate();
|
forceWaveformUpdate();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (cancelled) {
|
if (cancelled) return;
|
||||||
return;
|
console.error(`Failed to load waveform preview for file ${fileId} (${file.fileName}):`, error);
|
||||||
}
|
|
||||||
console.error(`Failed to load waveform preview for file ${file.id} (${file.fileName}):`, error);
|
|
||||||
const seed = file.checksum ?? file.absolutePath;
|
const seed = file.checksum ?? file.absolutePath;
|
||||||
waveformCacheRef.current[file.id] = buildWaveformVisual(seed, null, null);
|
waveformCacheRef.current[fileId] = buildWaveformVisual(seed, null, null);
|
||||||
|
waveformLoadedRef.current.add(fileId);
|
||||||
forceWaveformUpdate();
|
forceWaveformUpdate();
|
||||||
|
} finally {
|
||||||
|
loadingSet.delete(fileId);
|
||||||
|
}
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
{
|
||||||
|
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]);
|
||||||
|
|
||||||
@@ -139,6 +169,7 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea
|
|||||||
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%">
|
||||||
|
|||||||
@@ -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();
|
||||||
const audioBuffer = await audioContext.decodeAudioData(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 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) {
|
||||||
|
if (!cancelled) {
|
||||||
console.warn('Failed to generate waveform', error);
|
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;
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
Reference in New Issue
Block a user