mirror of
https://github.com/litruv/AudioSort.git
synced 2026-07-27 04:06:02 +10:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 88d87b4aa1 | |||
| 22e1d7eab9 | |||
| 5f7aff8381 | |||
| 84cc921c70 | |||
| d3e596e9a9 | |||
| 140cc18a31 | |||
| c6df01061a | |||
| 720fdf8946 | |||
| e3f4bfc7e2 | |||
| 4d3d616574 | |||
| 93d0da3496 | |||
| 761b1574e7 | |||
| 953c22bec3 | |||
| acf4b8d8b7 | |||
| fe38e7e8f2 | |||
| e16abd7370 | |||
| 9ade1cc7dd | |||
| 7ecb65153c | |||
| 65dadc9e3f | |||
| 9e487fed7b | |||
| e16b069ddd | |||
| 947a510646 | |||
| 343efc18ed | |||
| c2df7198bd | |||
| 69d98c1f1a | |||
| ed3d9b60df |
88
.github/workflows/build-all.yml
vendored
Normal file
88
.github/workflows/build-all.yml
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
name: Build All Platforms
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build-windows:
|
||||
runs-on: [self-hosted, windows]
|
||||
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 Windows
|
||||
run: npm run dist:win
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload Windows artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-installer
|
||||
path: release/*.exe
|
||||
|
||||
build-linux:
|
||||
runs-on: [self-hosted, linux]
|
||||
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
|
||||
run: npm run dist:linux
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload Linux artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-packages
|
||||
path: |
|
||||
release/*.AppImage
|
||||
release/*.deb
|
||||
release/*.rpm
|
||||
|
||||
release:
|
||||
needs: [build-windows, build-linux]
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
steps:
|
||||
- name: Download Windows artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: windows-installer
|
||||
|
||||
- name: Download Linux artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: linux-packages
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: |
|
||||
*.exe
|
||||
*.AppImage
|
||||
*.deb
|
||||
*.rpm
|
||||
draft: false
|
||||
prerelease: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
29
.github/workflows/build.yml
vendored
29
.github/workflows/build.yml
vendored
@@ -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
|
||||
56
README.md
56
README.md
@@ -1,7 +1,7 @@
|
||||
# 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
|
||||
|
||||
### From Release
|
||||
### Windows
|
||||
Download the latest `AudioSort Setup.exe` from the [Releases](../../releases) page and run the installer.
|
||||
|
||||
### Linux
|
||||
|
||||
#### AppImage (Universal)
|
||||
```bash
|
||||
# Download from Releases page
|
||||
chmod +x AudioSort-*.AppImage
|
||||
./AudioSort-*.AppImage
|
||||
```
|
||||
|
||||
#### Debian/Ubuntu (.deb)
|
||||
```bash
|
||||
sudo dpkg -i audio-sort_*.deb
|
||||
sudo apt-get install -f # Install dependencies if needed
|
||||
```
|
||||
|
||||
#### Fedora/RHEL (.rpm)
|
||||
```bash
|
||||
sudo dnf install ./audio-sort-*.rpm
|
||||
```
|
||||
|
||||
### From Source
|
||||
|
||||
#### Prerequisites
|
||||
@@ -126,23 +146,27 @@ AudioSort/
|
||||
|
||||
### Scripts
|
||||
```bash
|
||||
npm run dev # Start development server with hot reload
|
||||
npm run build # Build renderer and main process
|
||||
npm run lint # Type-check with TypeScript
|
||||
npm run pack # Package without installer
|
||||
npm run dist # Create distributable
|
||||
npm run dist:win # Create Windows installer
|
||||
npm run dev # Start development server with hot reload
|
||||
npm run build # Build renderer and main process
|
||||
npm run lint # Type-check with TypeScript
|
||||
npm run pack # Package without installer
|
||||
npm run dist # Create distributable
|
||||
npm run dist:win # Create Windows installer
|
||||
npm run dist:linux # Create Linux packages (AppImage, deb, rpm)
|
||||
```
|
||||
|
||||
## Configuration
|
||||
## Linux Distribution
|
||||
|
||||
### Build Configuration
|
||||
Edit `package.json` `build` section to customize:
|
||||
- Application name and ID
|
||||
- Icon and branding
|
||||
- Installer options
|
||||
- File associations
|
||||
For detailed information about building and distributing on Linux, see [LINUX_BUILD.md](LINUX_BUILD.md).
|
||||
|
||||
Supported formats:
|
||||
- **AppImage** - Universal, runs on any distribution
|
||||
- **DEB** - Debian, Ubuntu, and derivatives
|
||||
- **RPM** - Fedora, RHEL, CentOS, openSUSE
|
||||
|
||||
## Configuration
|
||||
|
||||
### Database Location
|
||||
User data is stored in:
|
||||
- **Windows**: `%APPDATA%/AudioSort/`
|
||||
- **Linux**: `~/.config/AudioSort/`
|
||||
|
||||
BIN
build/icon.ico
BIN
build/icon.ico
Binary file not shown.
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 119 KiB |
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,7 +1,12 @@
|
||||
{
|
||||
"name": "audio-sort",
|
||||
"version": "0.3.2",
|
||||
"version": "0.5.10",
|
||||
"description": "Electron audio sorting application with fuzzy search, tagging, library management, and advanced waveform editing with audio splitting capabilities for WAV files.",
|
||||
"author": {
|
||||
"name": "litruv",
|
||||
"email": "litruv@example.com",
|
||||
"url": "https://lit.ruv.wtf"
|
||||
},
|
||||
"main": "dist/main/main/index.js",
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
@@ -17,6 +22,7 @@
|
||||
"pack": "npm run build && electron-builder --dir",
|
||||
"dist": "npm run build && electron-builder",
|
||||
"dist:win": "npm run build && electron-builder --win",
|
||||
"dist:linux": "npm run build && electron-builder --linux",
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -66,6 +72,20 @@
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
"AppImage",
|
||||
"deb",
|
||||
"rpm"
|
||||
],
|
||||
"icon": "build/icon.png",
|
||||
"category": "AudioVideo",
|
||||
"description": "Audio sorting and management application",
|
||||
"mimeTypes": [
|
||||
"audio/wav",
|
||||
"audio/x-wav"
|
||||
]
|
||||
},
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "data/UCS.csv",
|
||||
|
||||
@@ -47,16 +47,22 @@ export class MainApp {
|
||||
|
||||
this.searchService.rebuildIndex();
|
||||
this.registerIpcHandlers();
|
||||
this.createMenu();
|
||||
await this.createMenu();
|
||||
this.createWindow();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the application menu.
|
||||
*/
|
||||
private createMenu(): void {
|
||||
private async createMenu(): Promise<void> {
|
||||
const isMac = process.platform === 'darwin';
|
||||
const drives = await this.listAvailableDrives();
|
||||
|
||||
const driveSubmenu: Electron.MenuItemConstructorOptions[] = drives.map((drive) => ({
|
||||
label: drive.label,
|
||||
click: () => this.mainWindow?.webContents.send('import-from-drive', drive.path)
|
||||
}));
|
||||
|
||||
const template: Electron.MenuItemConstructorOptions[] = [
|
||||
...(isMac ? [{
|
||||
label: app.name,
|
||||
@@ -79,6 +85,16 @@ export class MainApp {
|
||||
{
|
||||
label: 'File',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Import From Folder...',
|
||||
accelerator: isMac ? 'Cmd+Shift+I' : 'Ctrl+Shift+I',
|
||||
click: () => this.mainWindow?.webContents.send('import-from-folder')
|
||||
},
|
||||
{
|
||||
label: 'Import From Drive',
|
||||
submenu: driveSubmenu.length > 0 ? driveSubmenu : [{ label: 'No drives available', enabled: false }]
|
||||
},
|
||||
{ type: 'separator' as const },
|
||||
{
|
||||
label: 'Rescan Library',
|
||||
accelerator: isMac ? 'Cmd+R' : 'Ctrl+R',
|
||||
@@ -132,6 +148,8 @@ export class MainApp {
|
||||
ipcMain.removeHandler(IPC_CHANNELS.settingsGet);
|
||||
ipcMain.removeHandler(IPC_CHANNELS.settingsSetLibrary);
|
||||
ipcMain.removeHandler(IPC_CHANNELS.dialogSelectLibrary);
|
||||
ipcMain.removeHandler(IPC_CHANNELS.dialogSelectImportFolder);
|
||||
ipcMain.removeHandler(IPC_CHANNELS.systemListDrives);
|
||||
ipcMain.removeHandler(IPC_CHANNELS.libraryScan);
|
||||
ipcMain.removeHandler(IPC_CHANNELS.libraryList);
|
||||
ipcMain.removeHandler(IPC_CHANNELS.libraryRename);
|
||||
@@ -143,6 +161,8 @@ export class MainApp {
|
||||
ipcMain.removeHandler(IPC_CHANNELS.libraryUpdateMetadata);
|
||||
ipcMain.removeHandler(IPC_CHANNELS.librarySplit);
|
||||
ipcMain.removeHandler(IPC_CHANNELS.libraryWaveformPreview);
|
||||
ipcMain.removeHandler(IPC_CHANNELS.libraryWaveformRange);
|
||||
ipcMain.removeHandler(IPC_CHANNELS.libraryImport);
|
||||
ipcMain.removeHandler(IPC_CHANNELS.tagsUpdate);
|
||||
ipcMain.removeHandler(IPC_CHANNELS.categoriesList);
|
||||
ipcMain.removeHandler(IPC_CHANNELS.searchQuery);
|
||||
@@ -215,7 +235,7 @@ export class MainApp {
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.dialogSelectLibrary, async () => {
|
||||
const options: Electron.OpenDialogOptions = { properties: ['openDirectory'] };
|
||||
const options: Electron.OpenDialogOptions = { properties: ['openDirectory'] };
|
||||
const targetWindow = this.mainWindow;
|
||||
const result = targetWindow
|
||||
? await dialog.showOpenDialog(targetWindow, options)
|
||||
@@ -223,6 +243,17 @@ export class MainApp {
|
||||
return result.canceled ? null : result.filePaths[0] ?? null;
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.dialogSelectImportFolder, async () => {
|
||||
const options: Electron.OpenDialogOptions = { properties: ['openDirectory'] };
|
||||
const targetWindow = this.mainWindow;
|
||||
const result = targetWindow
|
||||
? await dialog.showOpenDialog(targetWindow, options)
|
||||
: await dialog.showOpenDialog(options);
|
||||
return result.canceled ? null : result.filePaths[0] ?? null;
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.systemListDrives, async () => this.listAvailableDrives());
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.libraryScan, async () => this.requireLibrary().scanLibrary());
|
||||
ipcMain.handle(IPC_CHANNELS.libraryList, async () => this.requireLibrary().listFiles());
|
||||
ipcMain.handle(IPC_CHANNELS.libraryGetById, async (_event: IpcMainInvokeEvent, fileId: number) =>
|
||||
@@ -283,6 +314,12 @@ export class MainApp {
|
||||
this.requireLibrary().getWaveformPreview(fileId, pointCount)
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.libraryWaveformRange,
|
||||
async (_event: IpcMainInvokeEvent, fileId: number, startMs: number, endMs: number) =>
|
||||
this.requireLibrary().getWaveformRange(fileId, startMs, endMs)
|
||||
);
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.tagsUpdate, async (_event: IpcMainInvokeEvent, payload: TagUpdatePayload) =>
|
||||
this.requireLibrary().updateTagging(payload)
|
||||
);
|
||||
@@ -306,6 +343,19 @@ export class MainApp {
|
||||
) =>
|
||||
this.requireLibrary().updateFileMetadata(fileId, metadata)
|
||||
);
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.libraryImport, async (_event: IpcMainInvokeEvent, payload: unknown) => {
|
||||
if (!Array.isArray(payload)) {
|
||||
throw new Error('Import request must provide an array of source paths.');
|
||||
}
|
||||
const sources = payload
|
||||
.map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
|
||||
.filter((entry) => entry.length > 0);
|
||||
if (sources.length === 0) {
|
||||
return { imported: [], skipped: [], failed: [] };
|
||||
}
|
||||
return this.requireLibrary().importExternalSources(sources);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -360,6 +410,86 @@ export class MainApp {
|
||||
return app.getAppPath();
|
||||
}
|
||||
|
||||
private async listAvailableDrives(): Promise<Array<{ path: string; label: string }>> {
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
const { exec } = await import('node:child_process');
|
||||
const { promisify } = await import('node:util');
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Query Windows Management Instrumentation for drive info
|
||||
const { stdout } = await execAsync('wmic logicaldisk get deviceid,drivetype', {
|
||||
timeout: 5000,
|
||||
windowsHide: true
|
||||
});
|
||||
|
||||
const lines = stdout.trim().split('\n').slice(1); // Skip header
|
||||
const drives: Array<{ path: string; type: string }> = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
// Parse "C: 3" format (DeviceID and DriveType)
|
||||
const match = trimmed.match(/^([A-Z]:)\s+(\d+)$/);
|
||||
if (!match) continue;
|
||||
|
||||
const [, deviceId, driveType] = match;
|
||||
const drivePath = `${deviceId}\\`;
|
||||
|
||||
// Verify drive is actually accessible before including it
|
||||
try {
|
||||
await fs.promises.access(drivePath);
|
||||
} catch {
|
||||
continue; // Skip if not accessible (unplugged/ejected)
|
||||
}
|
||||
|
||||
// DriveType: 2=Removable, 3=Local Fixed, 4=Network, 5=CD-ROM, 6=RAM Disk
|
||||
let label = drivePath;
|
||||
if (driveType === '2') {
|
||||
label = `${drivePath} (Removable)`;
|
||||
}
|
||||
|
||||
drives.push({ path: drivePath, type: label });
|
||||
}
|
||||
|
||||
return drives.sort((a, b) => a.path.localeCompare(b.path)).map((d) => ({ path: d.path, label: d.type }));
|
||||
} catch (error) {
|
||||
console.warn('Failed to query drive types via wmic, falling back to simple enumeration', error);
|
||||
// Fallback to basic enumeration
|
||||
const drives: { path: string; label: string }[] = [];
|
||||
const probes: Promise<void>[] = [];
|
||||
for (let code = 65; code <= 90; code += 1) {
|
||||
const letter = String.fromCharCode(code);
|
||||
const drivePath = `${letter}:\\`;
|
||||
const probe = fs.promises
|
||||
.access(drivePath)
|
||||
.then(() => {
|
||||
drives.push({ path: drivePath, label: drivePath });
|
||||
})
|
||||
.catch(() => undefined);
|
||||
probes.push(probe);
|
||||
}
|
||||
await Promise.all(probes);
|
||||
return drives.sort((a, b) => a.path.localeCompare(b.path));
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
try {
|
||||
const entries = await fs.promises.readdir('/Volumes', { withFileTypes: true });
|
||||
const volumes = entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => ({ path: path.join('/Volumes', entry.name), label: path.join('/Volumes', entry.name) }));
|
||||
return volumes.length > 0 ? volumes : [{ path: '/', label: '/' }];
|
||||
} catch {
|
||||
return [{ path: '/', label: '/' }];
|
||||
}
|
||||
}
|
||||
|
||||
return [{ path: '/', label: '/' }];
|
||||
}
|
||||
|
||||
private buildSearchBases(): string[] {
|
||||
const bases = new Set<string>();
|
||||
const appPath = app.getAppPath();
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { app } from 'electron';
|
||||
import { MainApp } from './MainApp';
|
||||
|
||||
// Disable GPU acceleration on Linux to avoid graphics driver issues
|
||||
if (process.platform === 'linux') {
|
||||
app.disableHardwareAcceleration();
|
||||
}
|
||||
|
||||
const mainApp = new MainApp();
|
||||
|
||||
app.whenReady()
|
||||
|
||||
@@ -410,6 +410,51 @@ export class DatabaseService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets cached waveform data for a file.
|
||||
*/
|
||||
public getWaveformCache(fileId: number, pointCount: number): { samples: number[]; rms: number } | null {
|
||||
const connection = this.requireDb();
|
||||
const row = connection
|
||||
.prepare('SELECT waveform_cache FROM files WHERE id = ?')
|
||||
.get(fileId) as { waveform_cache: string | null } | undefined;
|
||||
|
||||
if (!row || !row.waveform_cache) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(row.waveform_cache) as { pointCount: number; samples: number[]; rms: number };
|
||||
if (parsed.pointCount === pointCount) {
|
||||
return { samples: parsed.samples, rms: parsed.rms };
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets cached waveform data for a file.
|
||||
*/
|
||||
public setWaveformCache(fileId: number, pointCount: number, samples: number[], rms: number): void {
|
||||
const connection = this.requireDb();
|
||||
const cacheData = JSON.stringify({ pointCount, samples, rms });
|
||||
connection
|
||||
.prepare('UPDATE files SET waveform_cache = ? WHERE id = ?')
|
||||
.run(cacheData, fileId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears waveform cache for a specific file.
|
||||
*/
|
||||
public clearWaveformCache(fileId: number): void {
|
||||
const connection = this.requireDb();
|
||||
connection
|
||||
.prepare('UPDATE files SET waveform_cache = NULL WHERE id = ?')
|
||||
.run(fileId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a raw database row to the strongly typed summary shape.
|
||||
*/
|
||||
@@ -500,6 +545,7 @@ export class DatabaseService {
|
||||
this.addColumnIfMissing(connection, 'files', 'checksum', 'TEXT');
|
||||
this.addColumnIfMissing(connection, 'files', 'custom_name', 'TEXT');
|
||||
this.addColumnIfMissing(connection, 'files', 'parent_file_id', 'INTEGER');
|
||||
this.addColumnIfMissing(connection, 'files', 'waveform_cache', 'TEXT');
|
||||
|
||||
// Create checksum index after ensuring column exists
|
||||
connection.exec('CREATE INDEX IF NOT EXISTS idx_files_checksum ON files(checksum)');
|
||||
|
||||
@@ -4,7 +4,18 @@ import { createHash } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import fg from 'fast-glob';
|
||||
import { parse } from 'csv-parse/sync';
|
||||
import { AppSettings, AudioBufferPayload, AudioFileSummary, CategoryRecord, LibraryScanSummary, SplitSegmentRequest, TagUpdatePayload } from '../../shared/models';
|
||||
import {
|
||||
AppSettings,
|
||||
AudioBufferPayload,
|
||||
AudioFileSummary,
|
||||
CategoryRecord,
|
||||
ImportFailureEntry,
|
||||
ImportSkipEntry,
|
||||
LibraryImportResult,
|
||||
LibraryScanSummary,
|
||||
SplitSegmentRequest,
|
||||
TagUpdatePayload
|
||||
} from '../../shared/models';
|
||||
import { DatabaseService, FileRecordInput } from './DatabaseService';
|
||||
import { SettingsService } from './SettingsService';
|
||||
import { TagService } from './TagService';
|
||||
@@ -292,6 +303,138 @@ export class LibraryService {
|
||||
return cleanedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports external WAV files from the provided sources, copying them into the library while
|
||||
* skipping duplicates based on the audio checksum. Imported files land under `_Imports/<date>`.
|
||||
*/
|
||||
public async importExternalSources(sourcePaths: string[]): Promise<LibraryImportResult> {
|
||||
// eslint-disable-next-line no-console -- Log import start for debugging.
|
||||
console.log('Starting import from:', sourcePaths);
|
||||
|
||||
if (!Array.isArray(sourcePaths) || sourcePaths.length === 0) {
|
||||
return { imported: [], skipped: [], failed: [] };
|
||||
}
|
||||
|
||||
const libraryRoot = this.settings.ensureLibraryPath();
|
||||
const normalisedLibraryRoot = this.normalizeAbsolutePath(libraryRoot);
|
||||
const libraryRootWithSlash = normalisedLibraryRoot.endsWith('/')
|
||||
? normalisedLibraryRoot
|
||||
: `${normalisedLibraryRoot}/`;
|
||||
|
||||
const existingFiles = this.database.listFiles();
|
||||
const knownChecksums = new Set<string>();
|
||||
const knownPaths = new Set<string>();
|
||||
for (const file of existingFiles) {
|
||||
if (typeof file.checksum === 'string' && file.checksum.length > 0) {
|
||||
knownChecksums.add(file.checksum);
|
||||
}
|
||||
knownPaths.add(this.normalizeAbsolutePath(file.absolutePath));
|
||||
}
|
||||
|
||||
const importFolderRelativeBase = path.join('_Imports', new Date().toISOString().slice(0, 10));
|
||||
const importFolderAbsolute = path.join(libraryRoot, importFolderRelativeBase);
|
||||
await fs.mkdir(importFolderAbsolute, { recursive: true });
|
||||
|
||||
const { files: candidateFiles, failures } = await this.collectImportCandidates(sourcePaths);
|
||||
|
||||
// eslint-disable-next-line no-console -- Log discovered files for debugging.
|
||||
console.log(`Found ${candidateFiles.length} candidate files, ${failures.length} collection failures`);
|
||||
if (failures.length > 0) {
|
||||
// eslint-disable-next-line no-console -- Log collection failures for debugging.
|
||||
console.log('Collection failures:', failures);
|
||||
}
|
||||
|
||||
const imported: AudioFileSummary[] = [];
|
||||
const skipped: ImportSkipEntry[] = [];
|
||||
const failed: ImportFailureEntry[] = [...failures];
|
||||
const usedNames = new Set<string>();
|
||||
const allowedExtensions = new Set(['.wav', '.wave']);
|
||||
|
||||
const folderRelativeNormalised = this.normalizeRelativePath(
|
||||
path.relative(libraryRoot, importFolderAbsolute)
|
||||
);
|
||||
const folderForJoin =
|
||||
folderRelativeNormalised === '.' || folderRelativeNormalised.length === 0
|
||||
? ''
|
||||
: folderRelativeNormalised;
|
||||
|
||||
for (const candidate of candidateFiles) {
|
||||
const extension = path.extname(candidate).toLowerCase();
|
||||
if (!allowedExtensions.has(extension)) {
|
||||
// eslint-disable-next-line no-console -- Log skip reason for debugging.
|
||||
console.log(`Skipping ${candidate}: unsupported extension ${extension}`);
|
||||
skipped.push({ path: candidate, reason: 'unsupported' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalisedCandidate = this.normalizeAbsolutePath(candidate);
|
||||
if (
|
||||
normalisedCandidate === normalisedLibraryRoot ||
|
||||
normalisedCandidate.startsWith(libraryRootWithSlash)
|
||||
) {
|
||||
// eslint-disable-next-line no-console -- Log skip reason for debugging.
|
||||
console.log(`Skipping ${candidate}: already inside library`);
|
||||
skipped.push({ path: candidate, reason: 'inside-library' });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (knownPaths.has(normalisedCandidate)) {
|
||||
// eslint-disable-next-line no-console -- Log skip reason for debugging.
|
||||
console.log(`Skipping ${candidate}: duplicate path`);
|
||||
skipped.push({ path: candidate, reason: 'duplicate' });
|
||||
continue;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console -- Log checksum computation for debugging.
|
||||
console.log(`Computing checksum for ${candidate}...`);
|
||||
const checksum = await this.computeFileChecksum(candidate);
|
||||
if (!checksum) {
|
||||
// eslint-disable-next-line no-console -- Log skip reason for debugging.
|
||||
console.log(`Skipping ${candidate}: checksum computation failed`);
|
||||
skipped.push({ path: candidate, reason: 'checksum' });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (knownChecksums.has(checksum)) {
|
||||
// eslint-disable-next-line no-console -- Log skip reason for debugging.
|
||||
console.log(`Skipping ${candidate}: duplicate checksum ${checksum}`);
|
||||
skipped.push({ path: candidate, reason: 'duplicate' });
|
||||
continue;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console -- Log import attempt for debugging.
|
||||
console.log(`Attempting to import ${candidate} with checksum ${checksum}...`);
|
||||
try {
|
||||
const record = await this.copyAndRegisterImportedFile({
|
||||
sourcePath: candidate,
|
||||
checksum,
|
||||
importFolderAbsolute,
|
||||
folderForJoin,
|
||||
libraryRoot,
|
||||
usedNames
|
||||
});
|
||||
imported.push(record);
|
||||
knownChecksums.add(checksum);
|
||||
knownPaths.add(this.normalizeAbsolutePath(record.absolutePath));
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
// eslint-disable-next-line no-console -- Log import failures for debugging.
|
||||
console.error(`Failed to import ${candidate}:`, errorMessage);
|
||||
failed.push({
|
||||
path: candidate,
|
||||
message: errorMessage
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (imported.length > 0) {
|
||||
this.resetMetadataSuggestionsCache();
|
||||
this.search.rebuildIndex();
|
||||
}
|
||||
|
||||
return { imported, skipped, failed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames a file while keeping it in the current directory.
|
||||
*/
|
||||
@@ -310,7 +453,7 @@ export class LibraryService {
|
||||
path.basename(targetPath),
|
||||
path.basename(targetPath, path.extname(targetPath))
|
||||
);
|
||||
this.waveformPreviewCache.delete(fileId);
|
||||
this.clearWaveformCache(fileId);
|
||||
this.search.rebuildIndex();
|
||||
return updated;
|
||||
}
|
||||
@@ -335,7 +478,7 @@ export class LibraryService {
|
||||
path.basename(targetPath),
|
||||
path.basename(targetPath, path.extname(targetPath))
|
||||
);
|
||||
this.waveformPreviewCache.delete(fileId);
|
||||
this.clearWaveformCache(fileId);
|
||||
this.search.rebuildIndex();
|
||||
return updated;
|
||||
}
|
||||
@@ -358,8 +501,17 @@ export class LibraryService {
|
||||
* Generates a lightweight waveform preview suitable for list rendering.
|
||||
*/
|
||||
public async getWaveformPreview(fileId: number, pointCount = 160): Promise<{ samples: number[]; rms: number }> {
|
||||
const startTime = performance.now();
|
||||
const record = this.database.getFileById(fileId);
|
||||
const effectivePoints = Math.min(Math.max(pointCount ?? 160, 32), 16384);
|
||||
|
||||
// Check database cache first
|
||||
const dbCache = this.database.getWaveformCache(fileId, effectivePoints);
|
||||
if (dbCache) {
|
||||
return dbCache;
|
||||
}
|
||||
|
||||
// Check memory cache
|
||||
const cacheHit = this.waveformPreviewCache.get(fileId);
|
||||
if (cacheHit && cacheHit.modifiedAt === record.modifiedAt && cacheHit.pointCount === effectivePoints) {
|
||||
return { samples: cacheHit.samples, rms: cacheHit.rms };
|
||||
@@ -368,6 +520,15 @@ export class LibraryService {
|
||||
try {
|
||||
const buffer = await fs.readFile(record.absolutePath);
|
||||
const wave = new WaveFile(buffer);
|
||||
|
||||
// For large files, use optimized sampling instead of processing all samples
|
||||
const fileSize = buffer.length;
|
||||
const useFastSampling = fileSize > 10 * 1024 * 1024; // 10MB threshold
|
||||
|
||||
if (useFastSampling) {
|
||||
return await this.getWaveformPreviewFast(fileId, record, wave, effectivePoints, buffer, startTime);
|
||||
}
|
||||
|
||||
const sampleBlock = wave.getSamples(false, Float64Array) as Float64Array | Float64Array[];
|
||||
const channels = (Array.isArray(sampleBlock) ? sampleBlock : [sampleBlock]) as Float64Array[];
|
||||
const firstChannel = channels[0];
|
||||
@@ -380,6 +541,7 @@ export class LibraryService {
|
||||
samples: fallback,
|
||||
rms: 0
|
||||
});
|
||||
this.database.setWaveformCache(fileId, effectivePoints, fallback, 0);
|
||||
return { samples: fallback, rms: 0 };
|
||||
}
|
||||
|
||||
@@ -418,6 +580,11 @@ export class LibraryService {
|
||||
samples,
|
||||
rms
|
||||
});
|
||||
this.database.setWaveformCache(fileId, effectivePoints, samples, rms);
|
||||
|
||||
const totalTime = performance.now() - startTime;
|
||||
const durationSec = record.durationMs ? (record.durationMs / 1000).toFixed(1) : 'unknown';
|
||||
console.log(`Waveform preview for ${record.fileName}: ${totalTime.toFixed(1)}ms (${durationSec}s audio, ${(buffer.length / 1024 / 1024).toFixed(1)}MB)`);
|
||||
|
||||
return { samples, rms };
|
||||
} catch (error) {
|
||||
@@ -429,16 +596,200 @@ export class LibraryService {
|
||||
samples: fallback,
|
||||
rms: 0
|
||||
});
|
||||
this.database.setWaveformCache(fileId, effectivePoints, fallback, 0);
|
||||
return { samples: fallback, rms: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fast waveform preview generation for large files using sparse sampling.
|
||||
* Reads raw audio data directly without extracting all samples.
|
||||
*/
|
||||
private async getWaveformPreviewFast(
|
||||
fileId: number,
|
||||
record: AudioFileSummary,
|
||||
wave: WaveFile,
|
||||
effectivePoints: number,
|
||||
buffer: Buffer,
|
||||
startTime: number
|
||||
): Promise<{ samples: number[]; rms: number }> {
|
||||
// Parse WAV header to find data chunk
|
||||
const fmt = wave.fmt as { numChannels: number; bitsPerSample: number };
|
||||
const numChannels = fmt.numChannels;
|
||||
const bytesPerSample = Math.floor(fmt.bitsPerSample / 8);
|
||||
const bytesPerFrame = numChannels * bytesPerSample;
|
||||
|
||||
// Find the data chunk in the buffer
|
||||
let dataOffset = 12; // Skip RIFF header
|
||||
let dataSize = 0;
|
||||
|
||||
while (dataOffset < buffer.length - 8) {
|
||||
const chunkId = buffer.toString('ascii', dataOffset, dataOffset + 4);
|
||||
const chunkSize = buffer.readUInt32LE(dataOffset + 4);
|
||||
|
||||
if (chunkId === 'data') {
|
||||
dataOffset += 8; // Skip chunk header
|
||||
dataSize = chunkSize;
|
||||
break;
|
||||
}
|
||||
|
||||
dataOffset += 8 + chunkSize;
|
||||
if (chunkSize % 2 === 1) dataOffset++; // Word-aligned
|
||||
}
|
||||
|
||||
if (dataSize === 0) {
|
||||
// Fallback if we can't find data chunk
|
||||
const fallback = new Array(effectivePoints).fill(0);
|
||||
this.waveformPreviewCache.set(fileId, {
|
||||
modifiedAt: record.modifiedAt,
|
||||
pointCount: effectivePoints,
|
||||
samples: fallback,
|
||||
rms: 0
|
||||
});
|
||||
return { samples: fallback, rms: 0 };
|
||||
}
|
||||
|
||||
const totalFrames = Math.floor(dataSize / bytesPerFrame);
|
||||
const framesPerPoint = Math.floor(totalFrames / effectivePoints);
|
||||
|
||||
// Sample at most 100 frames per point for large files
|
||||
const framesToSamplePerPoint = Math.min(framesPerPoint, 100);
|
||||
const frameStep = Math.max(1, Math.floor(framesPerPoint / framesToSamplePerPoint));
|
||||
|
||||
const peaks: number[] = [];
|
||||
let sumSquares = 0;
|
||||
let sampleTotal = 0;
|
||||
|
||||
// Determine max value for normalization based on bit depth
|
||||
const maxValue = Math.pow(2, fmt.bitsPerSample - 1);
|
||||
|
||||
for (let pointIndex = 0; pointIndex < effectivePoints; pointIndex++) {
|
||||
const startFrame = pointIndex * framesPerPoint;
|
||||
const endFrame = pointIndex === effectivePoints - 1
|
||||
? totalFrames
|
||||
: Math.min(totalFrames, startFrame + framesPerPoint);
|
||||
|
||||
let blockPeak = 0;
|
||||
|
||||
for (let frame = startFrame; frame < endFrame; frame += frameStep) {
|
||||
const byteOffset = dataOffset + (frame * bytesPerFrame);
|
||||
|
||||
// Read samples from all channels at this frame
|
||||
for (let ch = 0; ch < numChannels; ch++) {
|
||||
const sampleOffset = byteOffset + (ch * bytesPerSample);
|
||||
|
||||
if (sampleOffset + bytesPerSample > buffer.length) break;
|
||||
|
||||
// Read sample based on bit depth
|
||||
let sampleValue = 0;
|
||||
if (bytesPerSample === 2) {
|
||||
sampleValue = buffer.readInt16LE(sampleOffset);
|
||||
} else if (bytesPerSample === 3) {
|
||||
// 24-bit
|
||||
const byte1 = buffer.readUInt8(sampleOffset);
|
||||
const byte2 = buffer.readUInt8(sampleOffset + 1);
|
||||
const byte3 = buffer.readInt8(sampleOffset + 2);
|
||||
sampleValue = (byte3 << 16) | (byte2 << 8) | byte1;
|
||||
} else if (bytesPerSample === 4) {
|
||||
sampleValue = buffer.readInt32LE(sampleOffset);
|
||||
} else if (bytesPerSample === 1) {
|
||||
sampleValue = buffer.readInt8(sampleOffset) << 8; // Convert 8-bit to 16-bit range
|
||||
}
|
||||
|
||||
const normalized = sampleValue / maxValue;
|
||||
const clamped = Math.max(-1, Math.min(1, normalized));
|
||||
const magnitude = Math.abs(clamped);
|
||||
|
||||
if (magnitude > blockPeak) {
|
||||
blockPeak = magnitude;
|
||||
}
|
||||
|
||||
sumSquares += clamped * clamped;
|
||||
sampleTotal++;
|
||||
}
|
||||
}
|
||||
|
||||
peaks.push(Math.min(blockPeak, 1));
|
||||
}
|
||||
|
||||
const samples = this.normaliseWaveformArray(peaks);
|
||||
const rms = sampleTotal > 0 ? Math.sqrt(sumSquares / sampleTotal) : 0;
|
||||
this.waveformPreviewCache.set(fileId, {
|
||||
modifiedAt: record.modifiedAt,
|
||||
pointCount: effectivePoints,
|
||||
samples,
|
||||
rms
|
||||
});
|
||||
this.database.setWaveformCache(fileId, effectivePoints, samples, rms);
|
||||
|
||||
const totalTime = performance.now() - startTime;
|
||||
const durationSec = record.durationMs ? (record.durationMs / 1000).toFixed(1) : 'unknown';
|
||||
console.log(`Waveform preview for ${record.fileName}: ${totalTime.toFixed(1)}ms (${durationSec}s audio, ${(buffer.length / 1024 / 1024).toFixed(1)}MB) [FAST]`);
|
||||
|
||||
return { samples, rms };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns full-resolution waveform samples for a specific time range.
|
||||
* Used for high-detail editor rendering when zoomed in.
|
||||
*/
|
||||
public async getWaveformRange(fileId: number, startMs: number, endMs: number): Promise<{ samples: number[] }> {
|
||||
const record = this.database.getFileById(fileId);
|
||||
|
||||
try {
|
||||
const buffer = await fs.readFile(record.absolutePath);
|
||||
const wave = new WaveFile(buffer);
|
||||
|
||||
// Get all samples for the entire file
|
||||
const sampleBlock = wave.getSamples(false, Float64Array) as Float64Array | Float64Array[];
|
||||
const channels = (Array.isArray(sampleBlock) ? sampleBlock : [sampleBlock]) as Float64Array[];
|
||||
const firstChannel = channels[0];
|
||||
|
||||
if (!firstChannel || firstChannel.length === 0 || !record.durationMs || record.durationMs <= 0) {
|
||||
return { samples: [] };
|
||||
}
|
||||
|
||||
const amplitudeScale = Math.max(1, this.resolveWaveformAmplitudeScale(wave));
|
||||
const totalDurationMs = record.durationMs;
|
||||
const samplesPerMs = firstChannel.length / totalDurationMs;
|
||||
|
||||
// Calculate sample indices for the requested time range
|
||||
const startSample = Math.floor(startMs * samplesPerMs);
|
||||
const endSample = Math.ceil(endMs * samplesPerMs);
|
||||
const clampedStart = Math.max(0, startSample);
|
||||
const clampedEnd = Math.min(firstChannel.length, endSample);
|
||||
|
||||
// Extract peaks for the range
|
||||
const samples: number[] = [];
|
||||
|
||||
for (let cursor = clampedStart; cursor < clampedEnd; cursor += 1) {
|
||||
let peak = 0;
|
||||
for (let channelIndex = 0; channelIndex < channels.length; channelIndex += 1) {
|
||||
const channel = channels[channelIndex];
|
||||
const rawSample = channel?.[cursor] ?? 0;
|
||||
const normalisedSample = rawSample / amplitudeScale;
|
||||
const clampedSample = Math.max(-1, Math.min(1, normalisedSample));
|
||||
const magnitude = Math.abs(clampedSample);
|
||||
if (magnitude > peak) {
|
||||
peak = magnitude;
|
||||
}
|
||||
}
|
||||
samples.push(Math.min(peak, 1));
|
||||
}
|
||||
|
||||
return { samples: this.normaliseWaveformArray(samples) };
|
||||
} catch (error) {
|
||||
console.warn('Failed to generate waveform range', { fileId, startMs, endMs, error });
|
||||
return { samples: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies tag updates, optionally re-running the organize pipeline when categories are present.
|
||||
*/
|
||||
public async updateTagging(payload: TagUpdatePayload): Promise<AudioFileSummary> {
|
||||
const updated = this.tagService.applyTagging(payload.fileId, payload.tags, payload.categories);
|
||||
this.waveformPreviewCache.delete(payload.fileId);
|
||||
this.clearWaveformCache(payload.fileId);
|
||||
|
||||
if (updated.categories.length > 0) {
|
||||
const metadataSnapshot = this.tagService.readMetadata(updated.absolutePath);
|
||||
@@ -588,7 +939,7 @@ export class LibraryService {
|
||||
}
|
||||
|
||||
this.resetMetadataSuggestionsCache();
|
||||
this.waveformPreviewCache.delete(fileId);
|
||||
this.clearWaveformCache(fileId);
|
||||
this.search.rebuildIndex();
|
||||
return updatedRecord;
|
||||
}
|
||||
@@ -684,7 +1035,7 @@ export class LibraryService {
|
||||
}
|
||||
|
||||
this.resetMetadataSuggestionsCache();
|
||||
this.waveformPreviewCache.delete(fileId);
|
||||
this.clearWaveformCache(fileId);
|
||||
this.search.rebuildIndex();
|
||||
return updated;
|
||||
}
|
||||
@@ -701,7 +1052,7 @@ export class LibraryService {
|
||||
console.error(`Failed to delete file ${record.absolutePath}:`, error);
|
||||
}
|
||||
this.database.deleteFile(fileId);
|
||||
this.waveformPreviewCache.delete(fileId);
|
||||
this.clearWaveformCache(fileId);
|
||||
}
|
||||
this.resetMetadataSuggestionsCache();
|
||||
this.search.rebuildIndex();
|
||||
@@ -925,11 +1276,11 @@ export class LibraryService {
|
||||
this.updateMetadataSuggestionsCache(resolvedAuthor);
|
||||
}
|
||||
|
||||
this.waveformPreviewCache.delete(updatedRecord.id);
|
||||
this.clearWaveformCache(updatedRecord.id);
|
||||
created.push(updatedRecord);
|
||||
}
|
||||
|
||||
this.waveformPreviewCache.delete(record.id);
|
||||
this.clearWaveformCache(record.id);
|
||||
this.resetMetadataSuggestionsCache();
|
||||
this.search.rebuildIndex();
|
||||
|
||||
@@ -1138,7 +1489,7 @@ export class LibraryService {
|
||||
this.updateMetadataSuggestionsCache(mergedMetadata.author);
|
||||
}
|
||||
|
||||
this.waveformPreviewCache.delete(fileId);
|
||||
this.clearWaveformCache(fileId);
|
||||
this.resetMetadataSuggestionsCache();
|
||||
}
|
||||
|
||||
@@ -1352,6 +1703,142 @@ export class LibraryService {
|
||||
return value.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gathers candidate audio files from the provided sources, handling both folders and individual files.
|
||||
*/
|
||||
private async collectImportCandidates(sourcePaths: string[]): Promise<{
|
||||
files: string[];
|
||||
failures: ImportFailureEntry[];
|
||||
}> {
|
||||
const discovered = new Set<string>();
|
||||
const failures: ImportFailureEntry[] = [];
|
||||
const uniqueSources = Array.from(
|
||||
new Set((sourcePaths ?? []).map((entry) => entry?.trim()).filter((entry): entry is string => Boolean(entry)))
|
||||
);
|
||||
|
||||
for (const rawSource of uniqueSources) {
|
||||
const absoluteSource = path.resolve(rawSource);
|
||||
try {
|
||||
const stats = await fs.stat(absoluteSource);
|
||||
if (stats.isDirectory()) {
|
||||
try {
|
||||
const matches = await fg(['**/*.wav', '**/*.wave'], {
|
||||
cwd: absoluteSource,
|
||||
absolute: true,
|
||||
onlyFiles: true,
|
||||
suppressErrors: true,
|
||||
caseSensitiveMatch: false
|
||||
});
|
||||
for (const match of matches) {
|
||||
discovered.add(path.resolve(match));
|
||||
}
|
||||
} catch (error) {
|
||||
failures.push({
|
||||
path: absoluteSource,
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
} else if (stats.isFile()) {
|
||||
discovered.add(absoluteSource);
|
||||
} else {
|
||||
failures.push({ path: absoluteSource, message: 'Unsupported file system entry.' });
|
||||
}
|
||||
} catch (error) {
|
||||
failures.push({
|
||||
path: absoluteSource,
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const files = Array.from(discovered).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
|
||||
return { files, failures };
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies a single source file into the library and registers it in the database, returning the stored record.
|
||||
*/
|
||||
private async copyAndRegisterImportedFile(options: {
|
||||
sourcePath: string;
|
||||
checksum: string;
|
||||
importFolderAbsolute: string;
|
||||
folderForJoin: string;
|
||||
libraryRoot: string;
|
||||
usedNames: Set<string>;
|
||||
}): Promise<AudioFileSummary> {
|
||||
const { sourcePath, checksum, importFolderAbsolute, folderForJoin, libraryRoot, usedNames } = options;
|
||||
const extension = path.extname(sourcePath);
|
||||
const baseName = path.basename(sourcePath, extension);
|
||||
let sanitizedBase = this.organization.sanitizeCustomName(baseName);
|
||||
if (!sanitizedBase) {
|
||||
sanitizedBase = 'Imported';
|
||||
}
|
||||
|
||||
let attempt = 0;
|
||||
let candidateName: string = '';
|
||||
let candidateAbsolutePath: string = '';
|
||||
while (true) {
|
||||
if (attempt === 0) {
|
||||
candidateName = this.normaliseFileName(`${sanitizedBase}.wav`);
|
||||
} else {
|
||||
const suffix = this.organization.formatSequenceNumber(attempt);
|
||||
candidateName = this.normaliseFileName(`${sanitizedBase}_${suffix}.wav`);
|
||||
}
|
||||
|
||||
if (!usedNames.has(candidateName)) {
|
||||
candidateAbsolutePath = path.join(importFolderAbsolute, candidateName);
|
||||
const exists = await this.pathExists(candidateAbsolutePath);
|
||||
if (!exists) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
attempt += 1;
|
||||
if (attempt > 9999) {
|
||||
throw new Error('Unable to allocate a unique filename for the imported file.');
|
||||
}
|
||||
}
|
||||
|
||||
usedNames.add(candidateName);
|
||||
this.assertWithinLibrary(libraryRoot, candidateAbsolutePath);
|
||||
|
||||
await fs.copyFile(sourcePath, candidateAbsolutePath);
|
||||
|
||||
try {
|
||||
const stats = await fs.stat(candidateAbsolutePath);
|
||||
const metadata = await this.extractAudioMetadata(candidateAbsolutePath);
|
||||
const relativePath = this.toLibraryRelativePath(folderForJoin, candidateName);
|
||||
const record = this.database.upsertFile({
|
||||
absolutePath: candidateAbsolutePath,
|
||||
relativePath,
|
||||
fileName: candidateName,
|
||||
displayName: path.basename(candidateName, path.extname(candidateName)),
|
||||
modifiedAt: stats.mtimeMs,
|
||||
createdAt: Number.isNaN(stats.birthtimeMs) ? null : stats.birthtimeMs,
|
||||
size: stats.size,
|
||||
durationMs: metadata.durationMs,
|
||||
sampleRate: metadata.sampleRate,
|
||||
bitDepth: metadata.bitDepth,
|
||||
checksum,
|
||||
tags: metadata.tags,
|
||||
categories: metadata.categories
|
||||
});
|
||||
|
||||
try {
|
||||
const embedded = this.tagService.readMetadata(candidateAbsolutePath);
|
||||
this.updateMetadataSuggestionsCache(embedded.author ?? null);
|
||||
} catch (metadataError) {
|
||||
// eslint-disable-next-line no-console -- Import should continue even if metadata read fails.
|
||||
console.warn('Failed to read metadata from imported file', metadataError);
|
||||
}
|
||||
|
||||
return record;
|
||||
} catch (error) {
|
||||
await fs.rm(candidateAbsolutePath, { force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves and caches the music-metadata parseFile implementation.
|
||||
*/
|
||||
@@ -1375,6 +1862,14 @@ export class LibraryService {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to clear both memory and database waveform cache.
|
||||
*/
|
||||
private clearWaveformCache(fileId: number): void {
|
||||
this.waveformPreviewCache.delete(fileId);
|
||||
this.database.clearWaveformCache(fileId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts metadata from the WAV container, falling back to defaults when parsing fails.
|
||||
*/
|
||||
@@ -1555,23 +2050,30 @@ export class LibraryService {
|
||||
|
||||
/**
|
||||
* Computes checksum from audio data only, excluding metadata chunks for stability.
|
||||
* Uses streaming approach to handle large files efficiently.
|
||||
*/
|
||||
private async computeFileChecksum(filePath: string): Promise<string | null> {
|
||||
try {
|
||||
const stats = await fs.stat(filePath);
|
||||
const fileSize = stats.size;
|
||||
|
||||
// For files larger than 100MB, use chunked streaming to avoid memory issues
|
||||
if (fileSize > 100 * 1024 * 1024) {
|
||||
return await this.computeFileChecksumStreaming(filePath);
|
||||
}
|
||||
|
||||
// For smaller files, use the existing fast method
|
||||
const buffer = await fs.readFile(filePath);
|
||||
const wave = new WaveFile(buffer);
|
||||
|
||||
// Hash only the raw audio samples, not metadata or other chunks
|
||||
const hash = createHash('md5');
|
||||
const samples = wave.getSamples();
|
||||
|
||||
if (Array.isArray(samples)) {
|
||||
// Multi-channel: hash each channel
|
||||
for (const channel of samples) {
|
||||
hash.update(Buffer.from(channel.buffer));
|
||||
}
|
||||
} else {
|
||||
// Single channel
|
||||
hash.update(Buffer.from(samples.buffer));
|
||||
}
|
||||
|
||||
@@ -1586,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.
|
||||
*/
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
AudioBufferPayload,
|
||||
AudioFileSummary,
|
||||
CategoryRecord,
|
||||
LibraryImportResult,
|
||||
LibraryScanSummary,
|
||||
SplitSegmentRequest,
|
||||
TagUpdatePayload
|
||||
@@ -26,9 +27,18 @@ const api: RendererApi = {
|
||||
async rescanLibrary(): Promise<LibraryScanSummary> {
|
||||
return ipcRenderer.invoke(IPC_CHANNELS.libraryScan);
|
||||
},
|
||||
async selectImportFolder(): Promise<string | null> {
|
||||
return ipcRenderer.invoke(IPC_CHANNELS.dialogSelectImportFolder);
|
||||
},
|
||||
async listSystemDrives(): Promise<string[]> {
|
||||
return ipcRenderer.invoke(IPC_CHANNELS.systemListDrives);
|
||||
},
|
||||
async listAudioFiles(): Promise<AudioFileSummary[]> {
|
||||
return ipcRenderer.invoke(IPC_CHANNELS.libraryList);
|
||||
},
|
||||
async importExternalSources(paths: string[]): Promise<LibraryImportResult> {
|
||||
return ipcRenderer.invoke(IPC_CHANNELS.libraryImport, paths);
|
||||
},
|
||||
/** Retrieves a single audio file summary by id, returning null when the record no longer exists. */
|
||||
async getAudioFileById(fileId: number): Promise<AudioFileSummary | null> {
|
||||
return ipcRenderer.invoke(IPC_CHANNELS.libraryGetById, fileId);
|
||||
@@ -66,6 +76,9 @@ const api: RendererApi = {
|
||||
async getWaveformPreview(fileId: number, pointCount = 160): Promise<{ samples: number[]; rms: number }> {
|
||||
return ipcRenderer.invoke(IPC_CHANNELS.libraryWaveformPreview, fileId, pointCount);
|
||||
},
|
||||
async getWaveformRange(fileId: number, startMs: number, endMs: number): Promise<{ samples: number[] }> {
|
||||
return ipcRenderer.invoke(IPC_CHANNELS.libraryWaveformRange, fileId, startMs, endMs);
|
||||
},
|
||||
async updateTagging(payload: TagUpdatePayload): Promise<AudioFileSummary> {
|
||||
return ipcRenderer.invoke(IPC_CHANNELS.tagsUpdate, payload);
|
||||
},
|
||||
@@ -87,8 +100,8 @@ const api: RendererApi = {
|
||||
): Promise<void> {
|
||||
return ipcRenderer.invoke(IPC_CHANNELS.libraryUpdateMetadata, fileId, metadata);
|
||||
},
|
||||
onMenuAction(channel: string, callback: () => void): () => void {
|
||||
const listener = () => callback();
|
||||
onMenuAction(channel: string, callback: (payload?: unknown) => void): () => void {
|
||||
const listener = (_event: Electron.IpcRendererEvent, payload?: unknown) => callback(payload);
|
||||
ipcRenderer.on(channel, listener);
|
||||
return () => ipcRenderer.removeListener(channel, listener);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState, type JSX } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState, type JSX } from 'react';
|
||||
import FileList from './components/FileList';
|
||||
import FileDetailPanel from './components/FileDetailPanel';
|
||||
import MultiFileEditor from './components/MultiFileEditor';
|
||||
@@ -10,7 +10,7 @@ import EditModePanel from './components/edit/EditModePanel';
|
||||
import { useLibrarySnapshot } from './hooks/useLibrarySnapshot';
|
||||
import { loadPlayerFile, usePlayerSnapshot } from './hooks/usePlayerSnapshot';
|
||||
import { libraryStore, type CategoryFilterValue } from './stores/LibraryStore';
|
||||
import type { AudioFileSummary } from '../../shared/models';
|
||||
import type { AudioFileSummary, LibraryImportResult } from '../../shared/models';
|
||||
|
||||
type RightPanelTab = 'listen' | 'edit';
|
||||
|
||||
@@ -25,14 +25,18 @@ function App(): JSX.Element {
|
||||
const [showStatusMessage, setShowStatusMessage] = useState(false);
|
||||
const [statusFadingOut, setStatusFadingOut] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<RightPanelTab>('listen');
|
||||
const [importMessage, setImportMessage] = useState<string | null>(null);
|
||||
|
||||
const statusMessage = useMemo(() => {
|
||||
if (importMessage) {
|
||||
return importMessage;
|
||||
}
|
||||
if (!library.lastScan) {
|
||||
return null;
|
||||
}
|
||||
const { added, updated, removed } = library.lastScan;
|
||||
return `Scan complete: +${added} updated ${updated} removed ${removed}`;
|
||||
}, [library.lastScan]);
|
||||
}, [importMessage, library.lastScan]);
|
||||
|
||||
useEffect(() => {
|
||||
if (statusMessage) {
|
||||
@@ -44,6 +48,7 @@ function App(): JSX.Element {
|
||||
const hideTimer = setTimeout(() => {
|
||||
setShowStatusMessage(false);
|
||||
setStatusFadingOut(false);
|
||||
setImportMessage((current) => (current === statusMessage ? null : current));
|
||||
}, 5000);
|
||||
return () => {
|
||||
clearTimeout(fadeTimer);
|
||||
@@ -52,18 +57,6 @@ function App(): JSX.Element {
|
||||
}
|
||||
}, [statusMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup1 = window.api.onMenuAction('open-settings', () => setSettingsOpen(true));
|
||||
const cleanup2 = window.api.onMenuAction('rescan-library', handleRescan);
|
||||
const cleanup3 = window.api.onMenuAction('find-duplicates', handleFindDuplicates);
|
||||
|
||||
return () => {
|
||||
cleanup1();
|
||||
cleanup2();
|
||||
cleanup3();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const selectedFile = useMemo(
|
||||
() => library.files.find((file) => file.id === library.selectedFileId) ?? null,
|
||||
[library.files, library.selectedFileId]
|
||||
@@ -155,14 +148,82 @@ function App(): JSX.Element {
|
||||
void libraryStore.search(value);
|
||||
};
|
||||
|
||||
const handleRescan = async () => {
|
||||
const handleRescan = useCallback(async () => {
|
||||
await libraryStore.rescan();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleFindDuplicates = async () => {
|
||||
const handleFindDuplicates = useCallback(async () => {
|
||||
const duplicates = await window.api.listDuplicates();
|
||||
setDuplicateGroups(duplicates);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const summariseImportResult = useCallback((result: LibraryImportResult): string => {
|
||||
const parts: string[] = [`${result.imported.length} added`];
|
||||
if (result.skipped.length > 0) {
|
||||
parts.push(`${result.skipped.length} skipped`);
|
||||
}
|
||||
if (result.failed.length > 0) {
|
||||
parts.push(`${result.failed.length} failed`);
|
||||
}
|
||||
return `Import complete: ${parts.join(', ')}`;
|
||||
}, []);
|
||||
|
||||
const notifyImportSuccess = useCallback((result: LibraryImportResult) => {
|
||||
setImportMessage(summariseImportResult(result));
|
||||
}, [summariseImportResult]);
|
||||
|
||||
const notifyImportFailure = useCallback(() => {
|
||||
setImportMessage('Import failed. Check logs for details.');
|
||||
}, []);
|
||||
|
||||
const handleImportFromFolder = useCallback(async () => {
|
||||
try {
|
||||
const result = await libraryStore.importFromFolder();
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
notifyImportSuccess(result);
|
||||
} catch (error) {
|
||||
console.error('Import failed', error);
|
||||
notifyImportFailure();
|
||||
}
|
||||
}, [notifyImportFailure, notifyImportSuccess]);
|
||||
|
||||
const handleImportFromDrive = useCallback(async (drive: string) => {
|
||||
try {
|
||||
const result = await libraryStore.importFromDrive(drive);
|
||||
notifyImportSuccess(result);
|
||||
} catch (error) {
|
||||
console.error('Import failed', error);
|
||||
notifyImportFailure();
|
||||
}
|
||||
}, [notifyImportFailure, notifyImportSuccess]);
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup1 = window.api.onMenuAction('open-settings', () => setSettingsOpen(true));
|
||||
const cleanup2 = window.api.onMenuAction('rescan-library', () => {
|
||||
void handleRescan();
|
||||
});
|
||||
const cleanup3 = window.api.onMenuAction('find-duplicates', () => {
|
||||
void handleFindDuplicates();
|
||||
});
|
||||
const cleanup4 = window.api.onMenuAction('import-from-folder', () => {
|
||||
void handleImportFromFolder();
|
||||
});
|
||||
const cleanup5 = window.api.onMenuAction('import-from-drive', (drive) => {
|
||||
if (typeof drive === 'string') {
|
||||
void handleImportFromDrive(drive);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cleanup1();
|
||||
cleanup2();
|
||||
cleanup3();
|
||||
cleanup4();
|
||||
cleanup5();
|
||||
};
|
||||
}, [handleFindDuplicates, handleImportFromFolder, handleImportFromDrive, handleRescan]);
|
||||
|
||||
const handleKeepDuplicate = async (fileIdToKeep: number, fileIdsToDelete: number[]) => {
|
||||
await window.api.deleteFiles(fileIdsToDelete);
|
||||
@@ -211,7 +272,7 @@ function App(): JSX.Element {
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
}, 100);
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
const handleUpdateCustomName = async (customName: string | null) => {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 119 KiB |
@@ -64,7 +64,7 @@ export interface AudioPlayerProps {
|
||||
/**
|
||||
* Seamless audio player that integrates smoothly with the interface.
|
||||
*/
|
||||
export function AudioPlayer({ snapshot }: AudioPlayerProps): JSX.Element {
|
||||
export function AudioPlayer({ snapshot }: AudioPlayerProps) {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
|
||||
78
src/renderer/src/components/DrivePickerDialog.tsx
Normal file
78
src/renderer/src/components/DrivePickerDialog.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { JSX } from 'react';
|
||||
|
||||
export interface DrivePickerDialogProps {
|
||||
drives: string[];
|
||||
loading: boolean;
|
||||
importing: boolean;
|
||||
error: string | null;
|
||||
onRefresh(): void;
|
||||
onSelect(drivePath: string): void;
|
||||
onClose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal dialog that lists available system drives for import selection.
|
||||
*/
|
||||
export function DrivePickerDialog({
|
||||
drives,
|
||||
loading,
|
||||
importing,
|
||||
error,
|
||||
onRefresh,
|
||||
onSelect,
|
||||
onClose
|
||||
}: DrivePickerDialogProps): JSX.Element {
|
||||
const handleBackdropClick = () => {
|
||||
if (!importing && !loading) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={handleBackdropClick}>
|
||||
<div className="modal-content drive-picker" onClick={(event) => event.stopPropagation()}>
|
||||
<header className="drive-picker__header">
|
||||
<h2>Import From Drive</h2>
|
||||
<p>Select a drive to copy new WAV files into your library.</p>
|
||||
</header>
|
||||
|
||||
{error ? <div className="drive-picker__error">{error}</div> : null}
|
||||
|
||||
<div className="drive-picker__body">
|
||||
{loading ? (
|
||||
<p className="drive-picker__status">Loading available drives…</p>
|
||||
) : drives.length === 0 ? (
|
||||
<p className="drive-picker__status">No drives were detected.</p>
|
||||
) : (
|
||||
<ul className="drive-picker__list">
|
||||
{drives.map((drive) => (
|
||||
<li key={drive}>
|
||||
<button
|
||||
type="button"
|
||||
className="drive-picker__drive"
|
||||
onClick={() => onSelect(drive)}
|
||||
disabled={importing}
|
||||
>
|
||||
<span className="drive-picker__drive-label">{drive}</span>
|
||||
<span className="drive-picker__drive-hint">Press Enter to import from this drive</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="drive-picker__footer">
|
||||
<button type="button" className="ghost-button" onClick={onRefresh} disabled={loading || importing}>
|
||||
Refresh Drives
|
||||
</button>
|
||||
<button type="button" className="ghost-button" onClick={onClose} disabled={importing}>
|
||||
Close
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DrivePickerDialog;
|
||||
@@ -14,9 +14,10 @@ export interface FileListProps {
|
||||
/**
|
||||
* Vertical list of WAV files with highlighting for the active selection.
|
||||
*/
|
||||
export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, searchValue, onSearchChange }: FileListProps): JSX.Element {
|
||||
export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, searchValue, onSearchChange }: FileListProps) {
|
||||
const buttonRefs = useRef(new Map<number, HTMLButtonElement>());
|
||||
const waveformCacheRef = useRef<Record<number, WaveformVisual>>({});
|
||||
const waveformLoadedRef = useRef<Set<number>>(new Set());
|
||||
const [, forceWaveformUpdate] = useReducer((value: number) => value + 1, 0);
|
||||
const dragGhostRef = useRef<HTMLElement | null>(null);
|
||||
const dragRotation = useRef<number>(0);
|
||||
@@ -57,34 +58,63 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const loadPreviews = async () => {
|
||||
for (const file of files) {
|
||||
if (waveformCacheRef.current[file.id]) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const preview = await window.api.getWaveformPreview(file.id, WAVEFORM_POINT_COUNT);
|
||||
if (cancelled) {
|
||||
return;
|
||||
const loadingSet = new Set<number>();
|
||||
|
||||
// Create intersection observer to load waveforms only for visible items
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
const button = entry.target as HTMLElement;
|
||||
const fileId = Number.parseInt(button.dataset.fileId ?? '0', 10);
|
||||
|
||||
if (!fileId || waveformCacheRef.current[fileId] || loadingSet.has(fileId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const file = files.find((f) => f.id === fileId);
|
||||
if (!file) continue;
|
||||
|
||||
loadingSet.add(fileId);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const preview = await window.api.getWaveformPreview(fileId, WAVEFORM_POINT_COUNT);
|
||||
if (cancelled) return;
|
||||
|
||||
const seed = file.checksum ?? file.absolutePath;
|
||||
waveformCacheRef.current[fileId] = buildWaveformVisual(seed, preview.samples, preview.rms);
|
||||
waveformLoadedRef.current.add(fileId);
|
||||
forceWaveformUpdate();
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
console.error(`Failed to load waveform preview for file ${fileId} (${file.fileName}):`, error);
|
||||
const seed = file.checksum ?? file.absolutePath;
|
||||
waveformCacheRef.current[fileId] = buildWaveformVisual(seed, null, null);
|
||||
waveformLoadedRef.current.add(fileId);
|
||||
forceWaveformUpdate();
|
||||
} finally {
|
||||
loadingSet.delete(fileId);
|
||||
}
|
||||
})();
|
||||
}
|
||||
const seed = file.checksum ?? file.absolutePath;
|
||||
waveformCacheRef.current[file.id] = buildWaveformVisual(seed, preview.samples, preview.rms);
|
||||
forceWaveformUpdate();
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
console.error(`Failed to load waveform preview for file ${file.id} (${file.fileName}):`, error);
|
||||
const seed = file.checksum ?? file.absolutePath;
|
||||
waveformCacheRef.current[file.id] = buildWaveformVisual(seed, null, null);
|
||||
forceWaveformUpdate();
|
||||
}
|
||||
},
|
||||
{
|
||||
root: null,
|
||||
rootMargin: '200px', // Load waveforms 200px before they come into view
|
||||
threshold: 0
|
||||
}
|
||||
};
|
||||
);
|
||||
|
||||
// Observe all file buttons
|
||||
for (const button of buttonRefs.current.values()) {
|
||||
observer.observe(button);
|
||||
}
|
||||
|
||||
void loadPreviews();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [files]);
|
||||
|
||||
@@ -138,7 +168,8 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea
|
||||
const primaryLabel = customLabel && customLabel.length > 0 ? customLabel : file.displayName;
|
||||
const seed = file.checksum ?? file.absolutePath;
|
||||
const gradientId = `waveGradient-${file.id}`;
|
||||
const wave = waveformCacheRef.current[file.id] ?? buildWaveformVisual(seed, null, null);
|
||||
const wave = waveformCacheRef.current[file.id] ?? buildWaveformVisual(seed, null, null);
|
||||
const hasLoadedWaveform = waveformLoadedRef.current.has(file.id);
|
||||
return (
|
||||
<button
|
||||
key={file.id}
|
||||
@@ -288,6 +319,12 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea
|
||||
viewBox={`0 0 ${WAVEFORM_WIDTH} ${WAVEFORM_HEIGHT}`}
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
transform: hasLoadedWaveform ? 'scaleY(1)' : 'scaleY(0)',
|
||||
transformOrigin: 'center',
|
||||
transition: 'transform 0.75s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
opacity: hasLoadedWaveform ? 1 : 0
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
export interface WaveformProps {
|
||||
audioUrl: string | null;
|
||||
@@ -9,25 +9,43 @@ export interface WaveformProps {
|
||||
|
||||
/**
|
||||
* Renders an audio waveform visualization that serves as the background for the progress bar.
|
||||
* Skips waveform generation for very long files to avoid UI lag.
|
||||
*/
|
||||
export function Waveform({ audioUrl, currentTime, duration, className = '' }: WaveformProps): JSX.Element {
|
||||
export function Waveform({ audioUrl, currentTime, duration, className = '' }: WaveformProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const waveformDataRef = useRef<number[] | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const loadWaveform = async () => {
|
||||
try {
|
||||
const audioContext = new AudioContext();
|
||||
setIsLoading(true);
|
||||
const startTime = performance.now();
|
||||
|
||||
const response = await fetch(audioUrl);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
const audioContext = new AudioContext();
|
||||
const decodeStart = performance.now();
|
||||
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
||||
const decodeTime = performance.now() - decodeStart;
|
||||
|
||||
if (cancelled) {
|
||||
await audioContext.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const processStart = performance.now();
|
||||
const rawData = audioBuffer.getChannelData(0);
|
||||
const samples = 500;
|
||||
const samples = 100; // Reduced from 500 to 100 for faster generation
|
||||
const blockSize = Math.floor(rawData.length / samples);
|
||||
const filteredData: number[] = [];
|
||||
|
||||
@@ -40,18 +58,36 @@ export function Waveform({ audioUrl, currentTime, duration, className = '' }: Wa
|
||||
filteredData.push(sum / blockSize);
|
||||
}
|
||||
|
||||
if (cancelled) {
|
||||
await audioContext.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const multiplier = Math.max(...filteredData) ** -1;
|
||||
waveformDataRef.current = filteredData.map((n) => n * multiplier);
|
||||
drawWaveform();
|
||||
|
||||
await audioContext.close();
|
||||
|
||||
const totalTime = performance.now() - startTime;
|
||||
const processTime = performance.now() - processStart;
|
||||
console.log(`Waveform generation: total=${totalTime.toFixed(1)}ms, decode=${decodeTime.toFixed(1)}ms, process=${processTime.toFixed(1)}ms, duration=${(duration / 1000).toFixed(1)}s`);
|
||||
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
console.warn('Failed to generate waveform', error);
|
||||
if (!cancelled) {
|
||||
console.warn('Failed to generate waveform', error);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadWaveform();
|
||||
}, [audioUrl]);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [audioUrl, duration]);
|
||||
|
||||
useEffect(() => {
|
||||
drawWaveform();
|
||||
@@ -100,7 +136,23 @@ export function Waveform({ audioUrl, currentTime, duration, className = '' }: Wa
|
||||
}
|
||||
};
|
||||
|
||||
return <canvas ref={canvasRef} className={className} style={{ width: '100%', height: '100%' }} />;
|
||||
return (
|
||||
<>
|
||||
<canvas ref={canvasRef} className={className} style={{ width: '100%', height: '100%' }} />
|
||||
{isLoading && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
fontSize: '10px',
|
||||
color: 'rgba(255, 255, 255, 0.5)'
|
||||
}}>
|
||||
Loading waveform...
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Waveform;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
AppSettings,
|
||||
AudioFileSummary,
|
||||
CategoryRecord,
|
||||
LibraryImportResult,
|
||||
LibraryScanSummary,
|
||||
SplitSegmentRequest,
|
||||
TagUpdatePayload
|
||||
@@ -60,8 +61,8 @@ export class LibraryStore extends EventTarget {
|
||||
window.api.listCategories(),
|
||||
window.api.listAudioFiles()
|
||||
]);
|
||||
const firstFileId = files.at(0)?.id ?? null;
|
||||
const firstFile = files.at(0) ?? null;
|
||||
const firstFileId = files[0]?.id ?? null;
|
||||
const firstFile = files[0] ?? null;
|
||||
const nextVersion = this.snapshot.metadataSuggestionsVersion + 1;
|
||||
this.snapshot = {
|
||||
initialized: true,
|
||||
@@ -252,7 +253,7 @@ export class LibraryStore extends EventTarget {
|
||||
...this.snapshot,
|
||||
files: results,
|
||||
searchQuery: query,
|
||||
selectedFileId: results.at(0)?.id ?? null,
|
||||
selectedFileId: results[0]?.id ?? null,
|
||||
focusedFile: this.snapshot.focusedFile
|
||||
};
|
||||
this.refreshVisibleFiles(this.snapshot.selectedFileId ?? null);
|
||||
@@ -299,7 +300,7 @@ export class LibraryStore extends EventTarget {
|
||||
this.snapshot = {
|
||||
...this.snapshot,
|
||||
files,
|
||||
selectedFileId: files.at(0)?.id ?? null,
|
||||
selectedFileId: files[0]?.id ?? null,
|
||||
lastScan: summary,
|
||||
focusedFile: this.snapshot.focusedFile,
|
||||
metadataSuggestionsVersion: this.snapshot.metadataSuggestionsVersion + 1
|
||||
@@ -308,6 +309,44 @@ export class LibraryStore extends EventTarget {
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompts the user to select a folder and imports audio files from it.
|
||||
*/
|
||||
public async importFromFolder(): Promise<LibraryImportResult | null> {
|
||||
const folder = await window.api.selectImportFolder();
|
||||
if (!folder) {
|
||||
return null;
|
||||
}
|
||||
return this.executeImport([folder]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports from a drive path, refreshing local caches when new files are added.
|
||||
*/
|
||||
public async importFromDrive(drivePath: string): Promise<LibraryImportResult> {
|
||||
return this.executeImport([drivePath]);
|
||||
}
|
||||
|
||||
private async executeImport(sources: string[]): Promise<LibraryImportResult> {
|
||||
const trimmed = sources.map((entry) => entry.trim()).filter((entry) => entry.length > 0);
|
||||
if (trimmed.length === 0) {
|
||||
return { imported: [], skipped: [], failed: [] };
|
||||
}
|
||||
|
||||
const result = await window.api.importExternalSources(trimmed);
|
||||
if (result.imported.length > 0) {
|
||||
const files = await window.api.listAudioFiles();
|
||||
const preferredId = result.imported[0]?.id ?? this.snapshot.selectedFileId ?? null;
|
||||
this.snapshot = {
|
||||
...this.snapshot,
|
||||
files,
|
||||
metadataSuggestionsVersion: this.snapshot.metadataSuggestionsVersion + 1
|
||||
};
|
||||
this.refreshVisibleFiles(preferredId ?? undefined);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies tag mutations to both the backend and the cached snapshot.
|
||||
*/
|
||||
@@ -415,7 +454,7 @@ export class LibraryStore extends EventTarget {
|
||||
.map((id) => fileLookup.get(id) ?? created.find((record) => record.id === id) ?? null)
|
||||
.filter((record): record is AudioFileSummary => record !== null);
|
||||
|
||||
const primaryId = justSplitRecords.at(0)?.id ?? null;
|
||||
const primaryId = justSplitRecords[0]?.id ?? null;
|
||||
const nextSelection = new Set<number>(justSplitRecords.map((record) => record.id));
|
||||
if (primaryId !== null) {
|
||||
nextSelection.add(primaryId);
|
||||
@@ -430,7 +469,7 @@ export class LibraryStore extends EventTarget {
|
||||
justSplitFileIds: justSplitRecords.map((record) => record.id),
|
||||
selectedFileId: primaryId,
|
||||
selectedFileIds: nextSelection,
|
||||
focusedFile: primaryId !== null ? fileLookup.get(primaryId) ?? justSplitRecords.at(0) ?? null : null
|
||||
focusedFile: primaryId !== null ? fileLookup.get(primaryId) ?? justSplitRecords[0] ?? null : null
|
||||
};
|
||||
this.emitChange();
|
||||
return created;
|
||||
@@ -446,7 +485,7 @@ export class LibraryStore extends EventTarget {
|
||||
...this.snapshot,
|
||||
settings,
|
||||
files,
|
||||
selectedFileId: files.at(0)?.id ?? null,
|
||||
selectedFileId: files[0]?.id ?? null,
|
||||
categoryFilter: null,
|
||||
focusedFile: this.snapshot.focusedFile,
|
||||
metadataSuggestionsVersion: this.snapshot.metadataSuggestionsVersion + 1
|
||||
@@ -542,7 +581,7 @@ export class LibraryStore extends EventTarget {
|
||||
}
|
||||
}
|
||||
if (enforceVisible) {
|
||||
return visible.at(0)?.id ?? null;
|
||||
return visible[0]?.id ?? null;
|
||||
}
|
||||
return desiredId;
|
||||
}
|
||||
|
||||
@@ -174,6 +174,95 @@ body {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.drive-picker {
|
||||
width: min(28rem, 90vw);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.drive-picker__header h2 {
|
||||
margin: 0 0 0.35rem 0;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.drive-picker__header p {
|
||||
margin: 0;
|
||||
opacity: 0.75;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.drive-picker__error {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.6rem;
|
||||
background: rgba(220, 97, 97, 0.12);
|
||||
border: 1px solid rgba(220, 97, 97, 0.35);
|
||||
color: #f5d6d6;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.drive-picker__body {
|
||||
min-height: 6rem;
|
||||
}
|
||||
|
||||
.drive-picker__status {
|
||||
margin: 0;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.drive-picker__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.drive-picker__drive {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.3rem;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border-radius: 0.6rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease, border-color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.drive-picker__drive:hover:not(:disabled),
|
||||
.drive-picker__drive:focus-visible {
|
||||
transform: translateY(-1px);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.drive-picker__drive:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.drive-picker__drive-label {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.drive-picker__drive-hint {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.drive-picker__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.status-banner {
|
||||
text-align: center;
|
||||
padding: 0.25rem 1rem;
|
||||
|
||||
@@ -3,6 +3,8 @@ export const IPC_CHANNELS = {
|
||||
settingsGet: 'settings:get',
|
||||
settingsSetLibrary: 'settings:set-library',
|
||||
dialogSelectLibrary: 'dialog:select-library',
|
||||
dialogSelectImportFolder: 'dialog:select-import-folder',
|
||||
systemListDrives: 'system:list-drives',
|
||||
libraryScan: 'library:scan',
|
||||
libraryList: 'library:list',
|
||||
libraryGetById: 'library:get-by-id',
|
||||
@@ -19,6 +21,8 @@ export const IPC_CHANNELS = {
|
||||
libraryMetadataSuggestions: 'library:metadata-suggestions',
|
||||
libraryUpdateMetadata: 'library:update-metadata',
|
||||
libraryWaveformPreview: 'library:waveform-preview',
|
||||
libraryWaveformRange: 'library:waveform-range',
|
||||
libraryImport: 'library:import',
|
||||
tagsUpdate: 'tags:update',
|
||||
categoriesList: 'categories:list',
|
||||
searchQuery: 'search:query'
|
||||
@@ -38,8 +42,14 @@ export interface RendererApi {
|
||||
setLibraryPath(path: string): Promise<import('./models').AppSettings>;
|
||||
/** Triggers a manual rescan of the library. */
|
||||
rescanLibrary(): Promise<import('./models').LibraryScanSummary>;
|
||||
/** Opens a dialog to pick a folder to import audio from. */
|
||||
selectImportFolder(): Promise<string | null>;
|
||||
/** Lists available system drives for drive-level imports. */
|
||||
listSystemDrives(): Promise<string[]>;
|
||||
/** Fetches the current list of audio files. */
|
||||
listAudioFiles(): Promise<import('./models').AudioFileSummary[]>;
|
||||
/** Imports external audio files into the library. */
|
||||
importExternalSources(paths: string[]): Promise<import('./models').LibraryImportResult>;
|
||||
/** Retrieves a single audio file summary by id, or null if it cannot be resolved. */
|
||||
getAudioFileById(fileId: number): Promise<import('./models').AudioFileSummary | null>;
|
||||
/** Fetches groups of duplicate files based on checksum. */
|
||||
@@ -65,6 +75,8 @@ export interface RendererApi {
|
||||
getAudioBuffer(fileId: number): Promise<import('./models').AudioBufferPayload>;
|
||||
/** Returns a lightweight waveform preview for rendering list backgrounds. */
|
||||
getWaveformPreview(fileId: number, pointCount?: number): Promise<{ samples: number[]; rms: number }>;
|
||||
/** Returns full-resolution waveform samples for a specific time range (for zoomed editor). */
|
||||
getWaveformRange(fileId: number, startMs: number, endMs: number): Promise<{ samples: number[] }>;
|
||||
/** Updates UCS categories for a file (free-form tags are preserved unless explicitly provided). */
|
||||
updateTagging(payload: import('./models').TagUpdatePayload): Promise<import('./models').AudioFileSummary>;
|
||||
/** Returns the catalog of UCS categories. */
|
||||
@@ -81,7 +93,7 @@ export interface RendererApi {
|
||||
metadata: { author?: string | null; copyright?: string | null; rating?: number }
|
||||
): Promise<void>;
|
||||
/** Listens for menu actions from the main process. Returns a cleanup function. */
|
||||
onMenuAction(channel: string, callback: () => void): () => void;
|
||||
onMenuAction(channel: string, callback: (payload?: unknown) => void): () => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -78,6 +78,35 @@ export interface LibraryScanSummary {
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** Enumerates reasons why an import candidate might be skipped. */
|
||||
export type ImportSkipReason = 'duplicate' | 'checksum' | 'unsupported' | 'inside-library';
|
||||
|
||||
/** Describes a source entry that was skipped during an import run. */
|
||||
export interface ImportSkipEntry {
|
||||
/** Absolute path of the skipped file. */
|
||||
path: string;
|
||||
/** Reason the file did not qualify for import. */
|
||||
reason: ImportSkipReason;
|
||||
}
|
||||
|
||||
/** Records a failure encountered while processing an import candidate. */
|
||||
export interface ImportFailureEntry {
|
||||
/** Absolute path of the problematic file or directory. */
|
||||
path: string;
|
||||
/** Human-readable message explaining the failure. */
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Summary returned after importing external audio sources. */
|
||||
export interface LibraryImportResult {
|
||||
/** Files successfully copied into the library. */
|
||||
imported: AudioFileSummary[];
|
||||
/** Candidates that were skipped with a known reason. */
|
||||
skipped: ImportSkipEntry[];
|
||||
/** Candidates that failed due to unexpected errors. */
|
||||
failed: ImportFailureEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape of persisted application settings.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user