39 Commits
v0.2.0 ... main

Author SHA1 Message Date
88d87b4aa1 Remove workflow_dispatch and set version to 0.5.10 2025-11-14 10:28:04 +11:00
22e1d7eab9 Bump version to 0.5.11 2025-11-14 10:13:31 +11:00
5f7aff8381 Bump version to 0.5.3 2025-11-14 10:08:23 +11:00
84cc921c70 feat: add waveform caching and range retrieval functionality 2025-11-14 10:03:31 +11:00
d3e596e9a9 fix: update README for improved Linux installation instructions and clarify application description 2025-11-12 08:19:40 +11:00
140cc18a31 fix: disable GPU acceleration on Linux to avoid graphics driver issues 2025-11-12 07:39:03 +11:00
c6df01061a revert: restore RPM target 2025-11-12 07:13:00 +11:00
720fdf8946 fix: remove RPM target (requires rpmbuild on runner) 2025-11-12 07:12:25 +11:00
e3f4bfc7e2 fix: use OS labels to distinguish self-hosted runners 2025-11-12 07:07:03 +11:00
4d3d616574 fix: add GH_TOKEN to build steps for electron-builder 2025-11-12 07:04:52 +11:00
93d0da3496 chore: use self-hosted runner for Linux builds 2025-11-12 07:01:51 +11:00
761b1574e7 fix: remove snap target to avoid publishing issues 2025-11-12 06:56:30 +11:00
953c22bec3 chore: use self-hosted runner for Windows builds 2025-11-12 06:54:40 +11:00
acf4b8d8b7 chore: add author website 2025-11-12 06:43:48 +11:00
fe38e7e8f2 fix: add author email and disable snap publishing 2025-11-12 06:42:57 +11:00
e16abd7370 fix: add PNG icon for Linux builds 2025-11-12 06:38:44 +11:00
9ade1cc7dd fix: only build on tags, not every push 2025-11-12 06:37:05 +11:00
7ecb65153c fix: use icon.ico for Linux builds 2025-11-12 06:35:18 +11:00
65dadc9e3f chore: consolidate to single build workflow 2025-11-12 06:34:22 +11:00
9e487fed7b fix: update dependency installation method in build workflow 2025-11-12 06:29:45 +11:00
e16b069ddd chore: bump version to 0.5.2 2025-11-12 06:28:24 +11:00
947a510646 linux building 2025-11-12 06:27:39 +11:00
343efc18ed feat: add external import with MD5 deduplication and drive detection
- Import from folder or removable drive via File menu
- MD5 checksum-based duplicate detection from audio content
- Windows drive detection with removable/fixed type labels
- Drive accessibility verification before listing
- Import files copied to _Imports/<date>/ folders
- Comprehensive logging for import debugging
2025-11-12 06:19:28 +11:00
c2df7198bd feat: implement drive import functionality 2025-11-12 06:18:06 +11:00
69d98c1f1a 0.4.0 2025-11-12 04:13:18 +11:00
ed3d9b60df chore: refresh app icon metadata 2025-11-12 04:12:50 +11:00
18a2d3ee26 0.3.2 2025-11-12 04:06:18 +11:00
cf6c14c161 docs: refresh readme and add app icons 2025-11-12 04:05:24 +11:00
524bbdce8a docs: update README with new feature 2025-11-12 02:56:50 +11:00
3569a46ebb 0.3.1 2025-11-12 02:46:33 +11:00
b4bfbd3ef6 chore: update dependencies and add patch-package for better-sqlite3
feat: implement just-split category filter and enhance metadata parsing
fix: improve type imports and variable handling in services
2025-11-12 02:45:07 +11:00
c33371b206 fix: update dependencies 2025-11-12 00:02:15 +11:00
4639494b9d chore: bump version to 0.3.0 2025-11-11 23:59:12 +11:00
7308e3c97c fix file by id in the parent field 2025-11-11 23:53:49 +11:00
5b2090c102 feat: add fade in/out functionality for audio segments with UI controls 2025-11-11 23:46:26 +11:00
e2017fc6ea removed text 2025-11-11 11:42:55 +11:00
e9c679dfaf - Updated package.json to include the UCS.csv file under the 'data' directory instead of the root.
- Modified MainApp.ts to resolve the path for UCS.csv from the 'data' directory.
- Deleted the unused test.wav file to clean up the repository.
2025-11-11 11:01:43 +11:00
d0f7b0fe18 chore: remove failing test harness 2025-11-11 10:56:41 +11:00
db1db50302 docs: update README with waveform editor features and new screenshot 2025-11-11 10:39:44 +11:00
42 changed files with 2054 additions and 1997 deletions

88
.github/workflows/build-all.yml vendored Normal file
View 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 }}

View File

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

1
.gitignore vendored
View File

@@ -64,3 +64,4 @@ test-data/
*.tmp
*.temp
.cache/
AudioSort.code-workspace

View File

@@ -1,7 +1,7 @@
# AudioSort
[![Build/release](https://github.com/litruv/AudioSort/actions/workflows/build.yml/badge.svg)](https://github.com/litruv/AudioSort/actions/workflows/build.yml)
[![Build All Platforms](https://github.com/litruv/AudioSort/actions/workflows/build-all.yml/badge.svg)](https://github.com/litruv/AudioSort/actions/workflows/build-all.yml)
A desktop application for organizing, tagging, and managing WAV audio files with Universal Category System (UCS) support.
A cross-platform desktop application for organizing, tagging, and managing WAV audio files with Universal Category System (UCS) support, featuring advanced waveform editing and audio splitting capabilities.
![AudioSort Interface](repoimages/interface.png)
@@ -16,6 +16,7 @@ A desktop application for organizing, tagging, and managing WAV audio files with
### Powerful Search & Organization
- Fuzzy search across filenames, tags, categories, and metadata
- Filter by category or view untagged files
- Quick "JUST SPLIT" filter surfaces freshly generated segments for rapid QC
- Multi-select support for batch operations
- Custom naming with automatic conflict resolution
@@ -27,33 +28,50 @@ A desktop application for organizing, tagging, and managing WAV audio files with
![Multi-file Editing](repoimages/multiediting.png)
### Waveform Editor
- Visual waveform editing with playback controls
- Segment-based audio splitting and trimming
- Real-time preview with zoom and pan controls
- Export individual segments with automatic naming
- Non-destructive editing workflow
![Waveform Editor](repoimages/editor.png)
### Beautiful Interface
- Real-time waveform visualization with dynamic color-coding based on RMS levels
- Waveform editor for splitting and trimming audio
- Smooth animations and polished drag-and-drop interactions
- Responsive file list with keyboard navigation (Arrow keys, Ctrl+A)
- Dark theme optimized for long sessions
![Smooth UI Demo](repoimages/smoothui.gif)
### Smart Library Management
- Automatic library scanning and indexing
- MD5 checksums for duplicate detection
- Metadata caching for instant access
- SQLite database for fast queries and reliable storage
## Technology Stack
- **Frontend**: React 18, TypeScript, Vite
- **Backend**: Electron 29, Node.js
- **Database**: better-sqlite3 with WAL mode
- **Audio Processing**: WaveFile, music-metadata
- **Search**: Fuse.js for fuzzy search
## 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
@@ -119,8 +137,8 @@ AudioSort/
│ │ └── hooks/ # Custom React hooks
│ ├── preload/ # Electron preload scripts
│ └── shared/ # Shared types and IPC definitions
├── data/ # User data directory (created at runtime)
── UCS.csv # Universal Category System catalog
├── data/
│ └── UCS.csv # Universal Category System catalog
└── repoimages/ # Documentation assets
```
@@ -128,33 +146,26 @@ 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 test # Run test suite
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)
```
### Testing
```bash
npm test # Run all tests
npm run test:database # Database service tests
npm run test:library # Library service tests
npm run test:search # Search service tests
npm run test:tag # Tag service tests
```
## Linux Distribution
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
### Build Configuration
Edit `package.json` `build` section to customize:
- Application name and ID
- Icon and branding
- Installer options
- File associations
### Database Location
User data is stored in:
- **Windows**: `%APPDATA%/AudioSort/`

View File

@@ -1,219 +0,0 @@
# AudioSort Test Suite
Comprehensive test suite for AudioSort application using real WAV files with embedded metadata.
## Running Tests
### Run all tests
```powershell
npm test
```
### Run specific test suites
```powershell
# Tag Service tests (metadata read/write)
npm run test:tag
# Library Service tests (scanning, organizing, file operations)
npm run test:library
# Search Service tests (fuzzy search, filters)
node --import tsx --test src/test/SearchService.test.ts
```
### Run individual test files
```powershell
node --import tsx --test src/test/DatabaseService.test.ts
node --import tsx --test src/test/TagService.test.ts
node --import tsx --test src/test/LibraryService.test.ts
node --import tsx --test src/test/SearchService.test.ts
```
## Test Coverage
### DatabaseService Tests (20+ tests)
- **File Management**: upsertFile, listFiles, getFileById, updateFileLocation, deleteFile
- **Tagging Operations**: updateTagging, updateCustomName
- **Category Management**: upsertCategory, getCategoryById, listCategories
- **Duplicate Detection**: listDuplicateGroups by checksum
- **Settings Persistence**: setSetting, getSettings
- Edge cases: Missing files, null values, duplicate checksums
### TagService Tests (14 tests)
- **readMetadata**: Reading author, copyright, title, rating from WAV files
- **writeMetadataOnly**: Writing metadata to WAV INFO chunks
- **applyTagging**: Updating tags/categories in database and WAV files
- Edge cases: Missing metadata, corrupted files, empty values, normalization
### LibraryService Tests (20+ tests)
- **scanLibrary**: File discovery, metadata extraction, temp file cleanup
- **listFiles**: File listing with metadata
- **renameFile**: File renaming on disk and in database
- **moveFile**: Moving files between directories
- **updateCustomName**: Custom name management
- **updateFileMetadata**: Metadata updates without organizing
- **readFileMetadata**: Reading embedded WAV metadata
- **listMetadataSuggestions**: Aggregating author/copyright suggestions
- **deleteFiles**: File deletion from disk and database
- **cleanupTempFiles**: Orphaned temp file recovery
### SearchService Tests (13 tests)
- **search**: Fuzzy search by filename, tags, path
- **Advanced filters**: author:, copyright: field-specific searches
- **Metadata caching**: Performance optimization
- **rebuildIndex**: Index updates after database changes
- Edge cases: Empty queries, typos, no matches
## Test Data
Tests use the `TestWavGenerator` class to create real WAV files with:
- Configurable duration, sample rate, bit depth
- Embedded metadata (author, copyright, title, rating)
- Tags and UCS categories
- Sine wave audio data (440 Hz)
Example test file creation:
```typescript
await TestWavGenerator.writeTestWav('test.wav', {
duration: 1,
sampleRate: 44100,
bitDepth: 16,
author: 'Test Author',
copyright: 'Test Copyright',
title: 'Test Title',
rating: 3,
tags: ['test', 'audio'],
categories: ['AMBNatr']
});
```
## Test Isolation
Each test suite:
1. Creates temporary database and library directories
2. Generates fresh test WAV files
3. Initializes services with isolated instances
4. Cleans up all resources after tests complete
Temporary files are created in `test-data/` and automatically removed.
## Testing Strategy
### Unit Tests
- Test individual service methods in isolation
- Verify correct behavior with valid inputs
- Handle edge cases and error conditions
### Integration Tests
- Test interactions between services (LibraryService + TagService + SearchService)
- Verify database updates reflect in search results
- Ensure file system operations complete successfully
### Real Data Tests
- Use actual WAV files with embedded metadata
- Test metadata round-trip (write then read)
- Verify file integrity after operations
## Extending Tests
### Adding New Test Cases
1. **For TagService**:
```typescript
it('should handle new metadata field', () => {
tagService.writeMetadataOnly(testFilePath, {
tags: [],
categories: [],
newField: 'value'
});
const metadata = tagService.readMetadata(testFilePath);
assert.strictEqual(metadata.newField, 'value');
});
```
2. **For LibraryService**:
```typescript
it('should handle new operation', async () => {
await libraryService.scanLibrary();
const files = libraryService.listFiles();
// Perform operation
await libraryService.newOperation(files[0].id);
// Verify results
const updated = libraryService.listFiles();
assert.ok(/* verification */);
});
```
3. **For SearchService**:
```typescript
it('should support new filter', () => {
const results = searchService.search('newfilter:value');
assert.ok(results.length > 0);
assert.ok(/* filter applied correctly */);
});
```
## Troubleshooting
### Tests hang or timeout
- Check for leaked file handles (ensure database.close() in afterEach)
- Verify temp files are being cleaned up
- Increase test timeout if needed
### File not found errors
- Ensure library path is created before writing test files
- Check file paths use correct separators for OS
- Verify temp directory permissions
### Metadata not persisting
- Confirm WAV file format is correct (use WaveFile library)
- Check INFO chunk tags are supported
- Verify file is written to disk before reading
### Search tests fail
- Rebuild search index after database changes
- Clear metadata cache when needed
- Check Fuse.js scoring thresholds
## Performance Considerations
- Tests create real WAV files (may be slower than mocks)
- Each test suite runs independently with fresh data
- Cleanup operations ensure no disk space accumulation
- Test data directory can be cleared manually if needed: `rm -rf test-data`
## CI/CD Integration
Tests can be run in CI/CD pipelines:
```yaml
# GitHub Actions example
- name: Run tests
run: npm test
```
Tests require:
- Node.js 18+ (for node:test runner)
- File system write access
- Sufficient disk space for test WAV files (~1-2 MB per test run)
## Debugging Tests
Enable verbose output:
```powershell
NODE_OPTIONS='--test-reporter=spec' npm test
```
Run specific test:
```powershell
node --import tsx --test --test-name-pattern="should read author" src/test/TagService.test.ts
```
Debug with inspector:
```powershell
node --import tsx --test --inspect-brk src/test/TagService.test.ts
```

BIN
build/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

BIN
build/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

View File

@@ -1,7 +1,12 @@
{
"name": "audio-sort",
"version": "0.2.0",
"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,59 +22,74 @@
"pack": "npm run build && electron-builder --dir",
"dist": "npm run build && electron-builder",
"dist:win": "npm run build && electron-builder --win",
"test": "node --import tsx --test src/test/DatabaseService.test.ts src/test/TagService.test.ts src/test/LibraryService.test.ts src/test/SearchService.test.ts",
"test:tag": "node --import tsx --test src/test/TagService.test.ts",
"test:library": "node --import tsx --test src/test/LibraryService.test.ts",
"test:database": "node --import tsx --test src/test/DatabaseService.test.ts",
"test:search": "node --import tsx --test src/test/SearchService.test.ts"
"dist:linux": "npm run build && electron-builder --linux",
"postinstall": "patch-package"
},
"dependencies": {
"better-sqlite3": "^11.0.0",
"csv-parse": "^5.5.5",
"fast-glob": "^3.3.2",
"fuse.js": "^6.6.2",
"music-metadata": "^10.5.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"better-sqlite3": "^12.4.1",
"csv-parse": "^6.1.0",
"fast-glob": "^3.3.3",
"fuse.js": "^7.1.0",
"music-metadata": "^11.10.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"standardized-audio-context": "^25.3.77",
"wavefile": "^11.0.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.8",
"@types/node": "^20.12.7",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.3",
"concurrently": "^8.2.2",
"cross-env": "^7.0.3",
"electron": "^32.0.0",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^24.10.0",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"electron": "^39.1.2",
"electron-builder": "^26.0.12",
"patch-package": "^8.0.1",
"tree-kill": "^1.2.2",
"tsx": "^4.15.7",
"typescript": "^5.3.3",
"vite": "^5.1.6"
"tsx": "^4.20.6",
"typescript": "^5.9.3",
"vite": "^7.2.2"
},
"build": {
"appId": "com.audiosort.app",
"productName": "AudioSort",
"files": [
"dist/**/*",
"UCS.csv"
"data/**/*"
],
"directories": {
"output": "release"
},
"win": {
"target": ["nsis"],
"target": [
"nsis"
],
"icon": "build/icon.ico"
},
"nsis": {
"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": "UCS.csv",
"to": "UCS.csv"
"from": "data/UCS.csv",
"to": "data/UCS.csv"
}
]
}

View File

@@ -0,0 +1,13 @@
diff --git a/node_modules/better-sqlite3/src/better_sqlite3.cpp b/node_modules/better-sqlite3/src/better_sqlite3.cpp
index 024bb4f..28a4d55 100644
--- a/node_modules/better-sqlite3/src/better_sqlite3.cpp
+++ b/node_modules/better-sqlite3/src/better_sqlite3.cpp
@@ -46,7 +46,7 @@ class Backup;
#include "objects/statement-iterator.cpp"
NODE_MODULE_INIT(/* exports, context */) {
- v8::Isolate* isolate = context->GetIsolate();
+ v8::Isolate* isolate = v8::Isolate::GetCurrent();
v8::HandleScope scope(isolate);
Addon::ConfigureURI();

BIN
repoimages/editor.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

View File

@@ -41,22 +41,28 @@ export class MainApp {
this.searchService
);
const catalogPath = this.resolveResourcePath('UCS.csv');
const catalogPath = this.resolveResourcePath(path.join('data', 'UCS.csv'));
await this.libraryService.ensureCategoriesLoaded(catalogPath);
await this.libraryService.scanLibrary();
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,8 +243,22 @@ 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) =>
this.requireLibrary().getFileById(fileId)
);
ipcMain.handle(IPC_CHANNELS.libraryDuplicates, async () => this.requireLibrary().listDuplicates());
ipcMain.handle(IPC_CHANNELS.searchQuery, async (_event: IpcMainInvokeEvent, query: string) =>
this.requireSearch().search(query)
@@ -280,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)
);
@@ -303,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);
});
}
/**
@@ -357,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();

View File

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

View File

@@ -410,6 +410,51 @@ export class DatabaseService {
};
}
/**
* Gets cached waveform data for a file.
*/
public getWaveformCache(fileId: number, pointCount: number): { samples: number[]; rms: number } | null {
const connection = this.requireDb();
const row = connection
.prepare('SELECT waveform_cache FROM files WHERE id = ?')
.get(fileId) as { waveform_cache: string | null } | undefined;
if (!row || !row.waveform_cache) {
return null;
}
try {
const parsed = JSON.parse(row.waveform_cache) as { pointCount: number; samples: number[]; rms: number };
if (parsed.pointCount === pointCount) {
return { samples: parsed.samples, rms: parsed.rms };
}
return null;
} catch {
return null;
}
}
/**
* Sets cached waveform data for a file.
*/
public setWaveformCache(fileId: number, pointCount: number, samples: number[], rms: number): void {
const connection = this.requireDb();
const cacheData = JSON.stringify({ pointCount, samples, rms });
connection
.prepare('UPDATE files SET waveform_cache = ? WHERE id = ?')
.run(cacheData, fileId);
}
/**
* Clears waveform cache for a specific file.
*/
public clearWaveformCache(fileId: number): void {
const connection = this.requireDb();
connection
.prepare('UPDATE files SET waveform_cache = NULL WHERE id = ?')
.run(fileId);
}
/**
* Maps a raw database row to the strongly typed summary shape.
*/
@@ -500,6 +545,7 @@ export class DatabaseService {
this.addColumnIfMissing(connection, 'files', 'checksum', 'TEXT');
this.addColumnIfMissing(connection, 'files', 'custom_name', 'TEXT');
this.addColumnIfMissing(connection, 'files', 'parent_file_id', 'INTEGER');
this.addColumnIfMissing(connection, 'files', 'waveform_cache', 'TEXT');
// Create checksum index after ensuring column exists
connection.exec('CREATE INDEX IF NOT EXISTS idx_files_checksum ON files(checksum)');

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import Fuse from 'fuse.js';
import Fuse, { type FuseResult, type IFuseOptions } from 'fuse.js';
import { AudioFileSummary } from '../../shared/models';
import { DatabaseService } from './DatabaseService';
import { TagService } from './TagService';
@@ -76,7 +76,7 @@ export class SearchService {
return fuseInstance
.search(remainingQuery)
.map((result: Fuse.FuseResult<AudioFileSummary>) => result.item)
.map((result: FuseResult<AudioFileSummary>) => result.item)
.filter((file) => (filters.length === 0 ? true : this.matchesAdvancedFilters(file, filters)));
}
@@ -103,7 +103,7 @@ export class SearchService {
/**
* Provides the standard Fuse configuration used by the service.
*/
private createFuseOptions(): Fuse.IFuseOptions<AudioFileSummary> {
private createFuseOptions(): IFuseOptions<AudioFileSummary> {
return {
includeScore: true,
threshold: 0.35,
@@ -114,7 +114,7 @@ export class SearchService {
{ name: 'tags', weight: 0.2 },
{ name: 'categories', weight: 0.1 }
]
} satisfies Fuse.IFuseOptions<AudioFileSummary>;
} satisfies IFuseOptions<AudioFileSummary>;
}
/**

View File

@@ -163,8 +163,8 @@ export class TagService {
.filter((entry) => entry.length > 0);
const tagText = tagValuesList.length > 0 ? tagValuesList.join('; ') : null;
const categoryText = categoryValuesList.length > 0 ? categoryValuesList.join('; ') : null;
const primaryCategory = categoryValuesList.at(0) ?? null;
const categoryText = categoryValuesList.length > 0 ? categoryValuesList.join('; ') : null;
const primaryCategory = categoryValuesList.length > 0 ? categoryValuesList[0] : null;
const trimmedTitle = metadata.title?.toString().trim();
const effectiveTitle = trimmedTitle && trimmedTitle.length > 0 ? trimmedTitle : null;
const trimmedAuthor = metadata.author?.toString().trim();

View File

@@ -0,0 +1,10 @@
declare module 'standardized-audio-context' {
export class OfflineAudioContext {
constructor(channelCount: number, length: number, sampleRate: number);
createBuffer(channelCount: number, length: number, sampleRate: number): any;
createBufferSource(): any;
createGain(): any;
startRendering(): Promise<any>;
readonly destination: any;
}
}

View File

@@ -5,6 +5,7 @@ import type {
AudioBufferPayload,
AudioFileSummary,
CategoryRecord,
LibraryImportResult,
LibraryScanSummary,
SplitSegmentRequest,
TagUpdatePayload
@@ -26,9 +27,22 @@ 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);
},
async listDuplicates(): Promise<{ checksum: string; files: AudioFileSummary[] }[]> {
return ipcRenderer.invoke(IPC_CHANNELS.libraryDuplicates);
},
@@ -62,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);
},
@@ -83,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);
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } 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,24 +57,12 @@ 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]
);
const parentFile = useMemo(() => {
const parentFileFromStore = useMemo(() => {
const parentId = selectedFile?.parentFileId ?? null;
if (parentId === null) {
return null;
@@ -77,6 +70,39 @@ function App(): JSX.Element {
return library.files.find((file) => file.id === parentId) ?? null;
}, [library.files, selectedFile?.parentFileId]);
const [resolvedParentFile, setResolvedParentFile] = useState<AudioFileSummary | null>(parentFileFromStore);
useEffect(() => {
const parentId = selectedFile?.parentFileId ?? null;
if (parentId === null) {
setResolvedParentFile(null);
return;
}
if (parentFileFromStore) {
setResolvedParentFile(parentFileFromStore);
return;
}
let cancelled = false;
(async () => {
try {
const fetched = await window.api.getAudioFileById(parentId);
if (!cancelled) {
setResolvedParentFile(fetched ?? null);
}
} catch (error) {
console.warn('Unable to resolve parent file details', { parentId, error });
if (!cancelled) {
setResolvedParentFile(null);
}
}
})();
return () => {
cancelled = true;
};
}, [parentFileFromStore, selectedFile?.parentFileId]);
const selectedFiles = useMemo(
() => library.files.filter((file) => library.selectedFileIds.has(file.id)),
[library.files, library.selectedFileIds]
@@ -122,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);
@@ -178,7 +272,7 @@ function App(): JSX.Element {
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}, 100);
}, 5000);
};
const handleUpdateCustomName = async (customName: string | null) => {
@@ -241,8 +335,7 @@ function App(): JSX.Element {
setActiveTab('listen');
};
const handleEditModeSplitComplete = async (created: AudioFileSummary[]) => {
await libraryStore.rescan();
const handleEditModeSplitComplete = (created: AudioFileSummary[]) => {
setActiveTab('listen');
if (created.length > 0) {
libraryStore.selectFile(created[0].id);
@@ -261,6 +354,7 @@ function App(): JSX.Element {
categories={library.categories}
files={library.files}
activeFilter={library.categoryFilter}
justSplitIds={library.justSplitFileIds}
onSelect={handleCategorySelect}
onDropFiles={handleDropFilesToCategory}
/>
@@ -307,7 +401,7 @@ function App(): JSX.Element {
<>
<FileDetailPanel
file={selectedFile}
parentFile={parentFile}
parentFile={resolvedParentFile}
categories={library.categories}
onRename={handleRename}
onMove={handleMove}

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

View File

@@ -64,7 +64,7 @@ export interface AudioPlayerProps {
/**
* Seamless audio player that integrates smoothly with the interface.
*/
export function AudioPlayer({ snapshot }: AudioPlayerProps): JSX.Element {
export function AudioPlayer({ snapshot }: AudioPlayerProps) {
const audioRef = useRef<HTMLAudioElement | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);

View File

@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import type { AudioFileSummary, CategoryRecord } from '../../../shared/models';
import { CATEGORY_FILTER_UNTAGGED, type CategoryFilterValue } from '../stores/LibraryStore';
import { CATEGORY_FILTER_JUST_SPLIT, CATEGORY_FILTER_UNTAGGED, type CategoryFilterValue } from '../stores/LibraryStore';
import {
buildCategorySwatch,
createCategoryStyleVars,
@@ -11,6 +11,8 @@ export interface CategorySidebarProps {
categories: CategoryRecord[];
files: AudioFileSummary[];
activeFilter: CategoryFilterValue;
/** Identifiers of files created during the most recent split action. */
justSplitIds: number[];
onSelect(filter: CategoryFilterValue): void;
onDropFiles?(fileIds: number[], categoryId: string): void;
}
@@ -37,7 +39,7 @@ function deriveCounts(files: AudioFileSummary[]): {
};
}
export function CategorySidebar({ categories, files, activeFilter, onSelect, onDropFiles }: CategorySidebarProps): JSX.Element {
export function CategorySidebar({ categories, files, activeFilter, justSplitIds, onSelect, onDropFiles }: CategorySidebarProps): JSX.Element {
const { total, untagged, categoryCounts } = deriveCounts(files);
const swatchMap = useMemo(() => {
const map = new Map<string, ReturnType<typeof buildCategorySwatch>>();
@@ -103,6 +105,17 @@ export function CategorySidebar({ categories, files, activeFilter, onSelect, onD
<span className="category-sidebar__label">TO TAG</span>
<span className="category-sidebar__count">{untagged}</span>
</button>
{justSplitIds.length > 0 ? (
<button
type="button"
className="category-sidebar__item"
data-active={activeFilter === CATEGORY_FILTER_JUST_SPLIT}
onClick={() => handleSelect(CATEGORY_FILTER_JUST_SPLIT)}
>
<span className="category-sidebar__label">JUST SPLIT</span>
<span className="category-sidebar__count">{justSplitIds.length}</span>
</button>
) : null}
</div>
{sortedGroups.map(([groupName, records]) => {
const sortedRecords = records

View 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;

View File

@@ -369,7 +369,7 @@ export function FileDetailPanel({ file, parentFile, categories, onRename, onMove
onClick={handleOpenParent}
title={parentFile ? `Open source file ${parentFile.displayName}` : 'Open source file'}
>
🔗 {parentFile ? parentFile.customName || parentFile.displayName : `File #${file.parentFileId}`}
🔗 {parentFile ? parentFile.customName || parentFile.displayName : `Source missing (#${file.parentFileId})`}
</button>
) : null}
</div>

View File

@@ -14,9 +14,10 @@ export interface FileListProps {
/**
* Vertical list of WAV files with highlighting for the active selection.
*/
export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, searchValue, onSearchChange }: FileListProps): JSX.Element {
export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, searchValue, onSearchChange }: FileListProps) {
const buttonRefs = useRef(new Map<number, HTMLButtonElement>());
const waveformCacheRef = useRef<Record<number, WaveformVisual>>({});
const waveformLoadedRef = useRef<Set<number>>(new Set());
const [, forceWaveformUpdate] = useReducer((value: number) => value + 1, 0);
const dragGhostRef = useRef<HTMLElement | null>(null);
const dragRotation = useRef<number>(0);
@@ -57,34 +58,63 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea
}
let cancelled = false;
const loadPreviews = async () => {
for (const file of files) {
if (waveformCacheRef.current[file.id]) {
continue;
}
try {
const preview = await window.api.getWaveformPreview(file.id, WAVEFORM_POINT_COUNT);
if (cancelled) {
return;
const loadingSet = new Set<number>();
// Create intersection observer to load waveforms only for visible items
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const button = entry.target as HTMLElement;
const fileId = Number.parseInt(button.dataset.fileId ?? '0', 10);
if (!fileId || waveformCacheRef.current[fileId] || loadingSet.has(fileId)) {
continue;
}
const file = files.find((f) => f.id === fileId);
if (!file) continue;
loadingSet.add(fileId);
void (async () => {
try {
const preview = await window.api.getWaveformPreview(fileId, WAVEFORM_POINT_COUNT);
if (cancelled) return;
const seed = file.checksum ?? file.absolutePath;
waveformCacheRef.current[fileId] = buildWaveformVisual(seed, preview.samples, preview.rms);
waveformLoadedRef.current.add(fileId);
forceWaveformUpdate();
} catch (error) {
if (cancelled) return;
console.error(`Failed to load waveform preview for file ${fileId} (${file.fileName}):`, error);
const seed = file.checksum ?? file.absolutePath;
waveformCacheRef.current[fileId] = buildWaveformVisual(seed, null, null);
waveformLoadedRef.current.add(fileId);
forceWaveformUpdate();
} finally {
loadingSet.delete(fileId);
}
})();
}
const seed = file.checksum ?? file.absolutePath;
waveformCacheRef.current[file.id] = buildWaveformVisual(seed, preview.samples, preview.rms);
forceWaveformUpdate();
} catch (error) {
if (cancelled) {
return;
}
console.error(`Failed to load waveform preview for file ${file.id} (${file.fileName}):`, error);
const seed = file.checksum ?? file.absolutePath;
waveformCacheRef.current[file.id] = buildWaveformVisual(seed, null, null);
forceWaveformUpdate();
}
},
{
root: null,
rootMargin: '200px', // Load waveforms 200px before they come into view
threshold: 0
}
};
);
// Observe all file buttons
for (const button of buttonRefs.current.values()) {
observer.observe(button);
}
void loadPreviews();
return () => {
cancelled = true;
observer.disconnect();
};
}, [files]);
@@ -138,7 +168,8 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea
const primaryLabel = customLabel && customLabel.length > 0 ? customLabel : file.displayName;
const seed = file.checksum ?? file.absolutePath;
const gradientId = `waveGradient-${file.id}`;
const wave = waveformCacheRef.current[file.id] ?? buildWaveformVisual(seed, null, null);
const wave = waveformCacheRef.current[file.id] ?? buildWaveformVisual(seed, null, null);
const hasLoadedWaveform = waveformLoadedRef.current.has(file.id);
return (
<button
key={file.id}
@@ -288,6 +319,12 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea
viewBox={`0 0 ${WAVEFORM_WIDTH} ${WAVEFORM_HEIGHT}`}
preserveAspectRatio="none"
aria-hidden="true"
style={{
transform: hasLoadedWaveform ? 'scaleY(1)' : 'scaleY(0)',
transformOrigin: 'center',
transition: 'transform 0.75s cubic-bezier(0.34, 1.56, 0.64, 1)',
opacity: hasLoadedWaveform ? 1 : 0
}}
>
<defs>
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="100%">

View File

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

View File

@@ -189,7 +189,39 @@ export function EditModePanel({ file, categories, onClose, onSplitComplete }: Ed
const playbackLengthMs = absoluteEndMs - playbackStartMs;
const source = context.createBufferSource();
source.buffer = buffer;
source.connect(context.destination);
// Create a gain node for fade control
const gainNode = context.createGain();
source.connect(gainNode);
gainNode.connect(context.destination);
// Apply fade in/out if playing a segment
if (request.mode === 'segment' && request.segmentId) {
const segment = segments.find((s) => s.id === request.segmentId);
if (segment) {
const fadeInMs = Math.min(segment.fadeInMs, playbackLengthMs / 2);
const fadeOutMs = Math.min(segment.fadeOutMs, playbackLengthMs / 2);
const currentTime = context.currentTime;
// Set initial gain
gainNode.gain.setValueAtTime(fadeInMs > 0 ? 0 : 1, currentTime);
// Apply fade in
if (fadeInMs > 0) {
const fadeInEndTime = currentTime + (fadeInMs / 1000);
gainNode.gain.linearRampToValueAtTime(1, fadeInEndTime);
}
// Apply fade out
if (fadeOutMs > 0) {
const fadeOutStartTime = currentTime + ((playbackLengthMs - fadeOutMs) / 1000);
const fadeOutEndTime = currentTime + (playbackLengthMs / 1000);
gainNode.gain.setValueAtTime(1, fadeOutStartTime);
gainNode.gain.linearRampToValueAtTime(0, fadeOutEndTime);
}
}
}
source.start(0, playbackStartMs / 1000, playbackLengthMs / 1000);
playbackInfoRef.current = { ...request };
@@ -230,7 +262,7 @@ export function EditModePanel({ file, categories, onClose, onSplitComplete }: Ed
stopPlaybackCursorTracking();
}
},
[ensureAudioContext, loadAudioBuffer, stopActiveSource, schedulePlaybackCursorUpdate, stopPlaybackCursorTracking]
[ensureAudioContext, loadAudioBuffer, stopActiveSource, schedulePlaybackCursorUpdate, stopPlaybackCursorTracking, segments]
);
const pausePlayback = useCallback(() => {
@@ -403,7 +435,9 @@ export function EditModePanel({ file, categories, onClose, onSplitComplete }: Ed
endMs: end,
label: `Segment ${segments.length + 1}`,
metadata: buildDefaultMetadata(file, baseMetadata),
color: generateSegmentColor(segments.length)
color: generateSegmentColor(segments.length),
fadeInMs: 0,
fadeOutMs: 0
};
setSegments((current) => {
const next = [...current, newSegment].sort((a, b) => a.startMs - b.startMs);
@@ -463,6 +497,16 @@ export function EditModePanel({ file, categories, onClose, onSplitComplete }: Ed
);
};
const handleUpdateFade = useCallback((segmentId: string, fadeInMs: number, fadeOutMs: number) => {
setSegments((current) =>
current.map((segment) =>
segment.id === segmentId
? { ...segment, fadeInMs, fadeOutMs }
: segment
)
);
}, []);
const handleRemoveSegment = useCallback((segmentId: string) => {
setSegments((current) => current.filter((segment) => segment.id !== segmentId));
setSelectedSegmentId((current) => (current === segmentId ? null : current));
@@ -570,6 +614,7 @@ export function EditModePanel({ file, categories, onClose, onSplitComplete }: Ed
onResizeSegment={handleResizeSegment}
onPlayFromCursor={playFromCursor}
playbackCursorMs={playbackCursorMs}
onUpdateFade={handleUpdateFade}
/>
)}
</div>

View File

@@ -21,6 +21,8 @@ export interface WaveformEditorCanvasProps {
onPlayFromCursor(startMs: number): void;
/** Location of the active playback cursor relative to the source audio, if playing. */
playbackCursorMs: number | null;
/** Invoked when fade in/out values change. */
onUpdateFade(segmentId: string, fadeInMs: number, fadeOutMs: number): void;
}
interface DraftSelection {
@@ -32,7 +34,8 @@ type DragState =
| { type: 'none' }
| { type: 'creating'; anchorMs: number; pointerMs: number }
| { type: 'resizing'; segmentId: string; handle: 'start' | 'end' }
| { type: 'panning'; startViewportMs: number; pointerStartX: number };
| { type: 'panning'; startViewportMs: number; pointerStartX: number }
| { type: 'fade'; segmentId: string; handle: 'fadeIn' | 'fadeOut'; initialMs: number };
const MIN_SEGMENT_MS = 50;
const MIN_VIEWPORT_MS = 200;
@@ -53,7 +56,8 @@ export function WaveformEditorCanvas({
onCreateSegment,
onResizeSegment,
onPlayFromCursor,
playbackCursorMs
playbackCursorMs,
onUpdateFade
}: WaveformEditorCanvasProps): JSX.Element {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const dragStateRef = useRef<DragState>({ type: 'none' });
@@ -179,13 +183,121 @@ export function WaveformEditorCanvas({
}
context.fillRect(startX, 0, endX - startX, height);
// Draw fade curves as bezier splines (volume envelope visualization)
const segmentDurationMs = segment.endMs - segment.startMs;
const fadeInMs = Math.min(segment.fadeInMs, segmentDurationMs / 2);
const fadeOutMs = Math.min(segment.fadeOutMs, segmentDurationMs / 2);
const fadeLineColor = isSelected ? '#ffcc00' : (rgbMatch ? `rgb(${parseInt(rgbMatch[1], 16)}, ${parseInt(rgbMatch[2], 16)}, ${parseInt(rgbMatch[3], 16)})` : '#ffffff');
// Draw fade in curve (volume ramp from 0 to 1)
if (fadeInMs > 0) {
const fadeInRatio = fadeInMs / viewportDuration;
const fadeInWidth = fadeInRatio * width;
const fadeInEndX = startX + fadeInWidth;
if (fadeInEndX > startX && fadeInEndX <= endX) {
context.strokeStyle = fadeLineColor;
context.lineWidth = 2.5;
context.beginPath();
// Start from bottom (silence) and curve up to full volume
context.moveTo(startX, height);
// Smooth S-curve using bezier - starts slow, accelerates, then eases in
const cp1X = startX + fadeInWidth * 0.3;
const cp1Y = height * 0.8;
const cp2X = startX + fadeInWidth * 0.7;
const cp2Y = height * 0.2;
context.bezierCurveTo(
cp1X, cp1Y,
cp2X, cp2Y,
fadeInEndX, 0
);
context.stroke();
// Add a subtle fill under the curve
context.globalAlpha = 0.1;
context.fillStyle = fadeLineColor;
context.lineTo(fadeInEndX, height);
context.lineTo(startX, height);
context.closePath();
context.fill();
context.globalAlpha = 1.0;
}
}
// Draw fade out curve (volume ramp from 1 to 0)
if (fadeOutMs > 0) {
const fadeOutRatio = fadeOutMs / viewportDuration;
const fadeOutWidth = fadeOutRatio * width;
const fadeOutStartX = endX - fadeOutWidth;
if (fadeOutStartX >= startX && fadeOutStartX < endX) {
context.strokeStyle = fadeLineColor;
context.lineWidth = 2.5;
context.beginPath();
// Start from top (full volume) and curve down to silence
context.moveTo(fadeOutStartX, 0);
// Smooth S-curve using bezier
const cp1X = fadeOutStartX + fadeOutWidth * 0.3;
const cp1Y = height * 0.2;
const cp2X = fadeOutStartX + fadeOutWidth * 0.7;
const cp2Y = height * 0.8;
context.bezierCurveTo(
cp1X, cp1Y,
cp2X, cp2Y,
endX, height
);
context.stroke();
// Add a subtle fill under the curve
context.globalAlpha = 0.1;
context.fillStyle = fadeLineColor;
context.lineTo(endX, height);
context.lineTo(fadeOutStartX, height);
context.closePath();
context.fill();
context.globalAlpha = 1.0;
}
}
// Use segment color for handles
context.fillStyle = isSelected ? '#ffcc00' : color;
context.fillRect(startX - HANDLE_WIDTH_PX / 2, 0, HANDLE_WIDTH_PX, height);
context.fillRect(endX - HANDLE_WIDTH_PX / 2, 0, HANDLE_WIDTH_PX, height);
// Draw corner handles for fade control (DaVinci Resolve style)
const dragState = dragStateRef.current;
const isFadeDragging = dragState.type === 'fade' && dragState.segmentId === segment.id;
const showFadeHandles = isSelected || isFadeDragging;
const cornerHandleSize = 12;
if (showFadeHandles) {
// Top-left corner handle for fade in
context.fillStyle = fadeInMs > 0 ? fadeLineColor : 'rgba(255, 255, 255, 0.3)';
context.beginPath();
context.moveTo(startX, 0);
context.lineTo(startX + cornerHandleSize, 0);
context.lineTo(startX, cornerHandleSize);
context.closePath();
context.fill();
// Top-right corner handle for fade out
context.fillStyle = fadeOutMs > 0 ? fadeLineColor : 'rgba(255, 255, 255, 0.3)';
context.beginPath();
context.moveTo(endX, 0);
context.lineTo(endX - cornerHandleSize, 0);
context.lineTo(endX, cornerHandleSize);
context.closePath();
context.fill();
}
// Draw segment labels when being resized or hovered
const dragState = dragStateRef.current;
const shouldShowLabels = (dragState.type === 'resizing' && dragState.segmentId === segment.id) ||
(hoveredSegmentId === segment.id && dragState.type === 'none');
@@ -449,6 +561,32 @@ export function WaveformEditorCanvas({
const viewport = viewportRef.current;
const toleranceMs = viewport.durationMs * (HANDLE_WIDTH_PX / Math.max(canvas?.clientWidth ?? 1, 1));
// Check for fade handles first (higher priority)
const rect = canvas?.getBoundingClientRect();
const pointerY = rect ? event.clientY - rect.top : 0;
const canvasWidth = canvas?.clientWidth ?? 1;
const canvasHeight = canvas?.clientHeight ?? 1;
const fadeHit = findFadeHandle(
pointerMs,
pointerY,
segments,
viewport.startMs,
viewport.durationMs,
canvasWidth,
canvasHeight
);
if (fadeHit) {
onSelectSegment(fadeHit.segmentId);
const segment = segments.find((s) => s.id === fadeHit.segmentId);
const initialMs = fadeHit.handle === 'fadeIn'
? (segment?.fadeInMs ?? 0)
: (segment?.fadeOutMs ?? 0);
dragStateRef.current = { type: 'fade', segmentId: fadeHit.segmentId, handle: fadeHit.handle, initialMs };
clickContextRef.current = null;
return;
}
const hit = findSegmentHandle(pointerMs, segments, toleranceMs);
if (hit) {
@@ -591,6 +729,24 @@ export function WaveformEditorCanvas({
const nextEnd = clamp(pointerMs, Math.max(minEnd, segment.startMs + MIN_SEGMENT_MS), durationMs);
onResizeSegment(segment.id, segment.startMs, nextEnd);
}
return;
}
if (state.type === 'fade') {
const segment = segments.find((entry) => entry.id === state.segmentId);
if (!segment) {
return;
}
const segmentDurationMs = segment.endMs - segment.startMs;
const maxFade = segmentDurationMs / 2;
if (state.handle === 'fadeIn') {
const fadeInMs = clamp(pointerMs - segment.startMs, 0, maxFade);
onUpdateFade(segment.id, fadeInMs, segment.fadeOutMs);
} else {
const fadeOutMs = clamp(segment.endMs - pointerMs, 0, maxFade);
onUpdateFade(segment.id, segment.fadeInMs, fadeOutMs);
}
}
};
@@ -854,6 +1010,73 @@ function findSegmentHandle(
return null;
}
/**
* Detects if the pointer is over a corner fade handle for a segment.
* Corner handles are triangular regions at the top-left and top-right of segments.
*
* @param pointerMs - Absolute timestamp within the audio file to test.
* @param pointerY - Y coordinate of the pointer in canvas pixels.
* @param segments - Segment collection to evaluate.
* @param viewportStartMs - Start of the current viewport.
* @param viewportDurationMs - Duration of the current viewport.
* @param canvasWidth - Width of the canvas in pixels.
* @param canvasHeight - Height of the canvas in pixels.
* @returns Fade handle information or null if no corner handle is under the pointer.
*/
function findFadeHandle(
pointerMs: number,
pointerY: number,
segments: SegmentDraft[],
viewportStartMs: number,
viewportDurationMs: number,
canvasWidth: number,
canvasHeight: number
): { segmentId: string; handle: 'fadeIn' | 'fadeOut' } | null {
const cornerHandleSize = 12;
const maxCornerY = 25; // Extended hit area
for (const segment of segments) {
const startRatio = (segment.startMs - viewportStartMs) / viewportDurationMs;
const endRatio = (segment.endMs - viewportStartMs) / viewportDurationMs;
const startX = Math.max(0, Math.min(canvasWidth, startRatio * canvasWidth));
const endX = Math.max(0, Math.min(canvasWidth, endRatio * canvasWidth));
if (endX <= startX) {
continue;
}
// Check if pointer is in the segment vertically
if (pointerMs < segment.startMs || pointerMs > segment.endMs) {
continue;
}
// Convert pointerMs to X coordinate
const pointerRatio = (pointerMs - viewportStartMs) / viewportDurationMs;
const pointerX = pointerRatio * canvasWidth;
// Check top-left corner (fade in)
if (pointerY <= maxCornerY && pointerX >= startX && pointerX <= startX + cornerHandleSize * 2) {
// Triangle hit test: point is in triangle if it's above the diagonal line
const relX = pointerX - startX;
const relY = pointerY;
if (relX + relY <= cornerHandleSize * 1.5) {
return { segmentId: segment.id, handle: 'fadeIn' };
}
}
// Check top-right corner (fade out)
if (pointerY <= maxCornerY && pointerX <= endX && pointerX >= endX - cornerHandleSize * 2) {
// Triangle hit test: point is in triangle if it's above the diagonal line
const relX = endX - pointerX;
const relY = pointerY;
if (relX + relY <= cornerHandleSize * 1.5) {
return { segmentId: segment.id, handle: 'fadeOut' };
}
}
}
return null;
}
/**
* Determine which segment should be chosen for interaction at the supplied timestamp.
* Prefers the segment whose midpoint is closest to the pointer when multiple segments overlap.

View File

@@ -32,6 +32,10 @@ export interface SegmentDraft {
metadata: SegmentMetadataDraft;
/** Color used for visual identification in UI and waveform. */
color: string;
/** Fade in duration in milliseconds. */
fadeInMs: number;
/** Fade out duration in milliseconds. */
fadeOutMs: number;
}
/**
@@ -57,6 +61,8 @@ export function toSplitRequest(segment: SegmentDraft): SplitSegmentRequest {
rating: segment.metadata.rating ?? undefined,
tags: segment.metadata.tags,
categories: segment.metadata.categories
}
},
fadeInMs: segment.fadeInMs > 0 ? Math.round(segment.fadeInMs) : undefined,
fadeOutMs: segment.fadeOutMs > 0 ? Math.round(segment.fadeOutMs) : undefined
};
}

View File

@@ -2,6 +2,7 @@ import type {
AppSettings,
AudioFileSummary,
CategoryRecord,
LibraryImportResult,
LibraryScanSummary,
SplitSegmentRequest,
TagUpdatePayload
@@ -10,7 +11,9 @@ import type {
type ChangeListener = () => void;
export const CATEGORY_FILTER_UNTAGGED = 'untagged' as const;
export type CategoryFilterValue = string | typeof CATEGORY_FILTER_UNTAGGED | null;
/** Filter key that exposes files generated by the most recent split action. */
export const CATEGORY_FILTER_JUST_SPLIT = 'just-split' as const;
export type CategoryFilterValue = string | typeof CATEGORY_FILTER_UNTAGGED | typeof CATEGORY_FILTER_JUST_SPLIT | null;
export interface LibrarySnapshot {
initialized: boolean;
@@ -25,6 +28,8 @@ export interface LibrarySnapshot {
categoryFilter: CategoryFilterValue;
lastScan: LibraryScanSummary | null;
metadataSuggestionsVersion: number;
/** Files created by the most recent split action during this app session. */
justSplitFileIds: number[];
}
/**
@@ -43,7 +48,8 @@ export class LibraryStore extends EventTarget {
searchQuery: '',
categoryFilter: null,
lastScan: null,
metadataSuggestionsVersion: 0
metadataSuggestionsVersion: 0,
justSplitFileIds: []
};
/**
@@ -55,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,
@@ -70,7 +76,8 @@ export class LibraryStore extends EventTarget {
searchQuery: '',
categoryFilter: null,
lastScan: null,
metadataSuggestionsVersion: nextVersion
metadataSuggestionsVersion: nextVersion,
justSplitFileIds: []
};
this.refreshVisibleFiles(this.snapshot.selectedFileId ?? null);
}
@@ -246,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);
@@ -263,7 +270,10 @@ export class LibraryStore extends EventTarget {
};
let visible: AudioFileSummary[];
if (filter === CATEGORY_FILTER_UNTAGGED) {
if (filter === CATEGORY_FILTER_JUST_SPLIT) {
const justSplitIds = new Set(this.snapshot.justSplitFileIds);
visible = files.filter((file) => justSplitIds.has(file.id));
} else if (filter === CATEGORY_FILTER_UNTAGGED) {
visible = files.filter((file) => file.categories.length === 0);
} else if (filter) {
visible = files.filter((file) => file.categories.includes(filter));
@@ -275,7 +285,8 @@ export class LibraryStore extends EventTarget {
...this.snapshot,
visibleFiles: visible,
selectedFileId,
focusedFile
focusedFile,
justSplitFileIds: this.snapshot.justSplitFileIds
};
this.emitChange();
}
@@ -289,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
@@ -298,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.
*/
@@ -398,21 +447,29 @@ export class LibraryStore extends EventTarget {
public async splitFile(fileId: number, segments: SplitSegmentRequest[]): Promise<AudioFileSummary[]> {
const created = await window.api.splitFile(fileId, segments);
const files = await window.api.listAudioFiles();
const { categoryFilter } = this.snapshot;
let visible: AudioFileSummary[];
if (categoryFilter === CATEGORY_FILTER_UNTAGGED) {
visible = files.filter((file) => file.categories.length === 0);
} else if (categoryFilter) {
visible = files.filter((file) => file.categories.includes(categoryFilter));
} else {
visible = files.slice();
const createdIds = created.map((record) => record.id);
const fileLookup = new Map(files.map((record) => [record.id, record] as const));
// Prefer fresh copies from the library list so downstream consumers see current metadata.
const justSplitRecords: AudioFileSummary[] = createdIds
.map((id) => fileLookup.get(id) ?? created.find((record) => record.id === id) ?? null)
.filter((record): record is AudioFileSummary => record !== null);
const primaryId = justSplitRecords[0]?.id ?? null;
const nextSelection = new Set<number>(justSplitRecords.map((record) => record.id));
if (primaryId !== null) {
nextSelection.add(primaryId);
}
this.snapshot = {
...this.snapshot,
files,
visibleFiles: visible,
metadataSuggestionsVersion: this.snapshot.metadataSuggestionsVersion + 1
visibleFiles: justSplitRecords,
metadataSuggestionsVersion: this.snapshot.metadataSuggestionsVersion + 1,
categoryFilter: CATEGORY_FILTER_JUST_SPLIT,
justSplitFileIds: justSplitRecords.map((record) => record.id),
selectedFileId: primaryId,
selectedFileIds: nextSelection,
focusedFile: primaryId !== null ? fileLookup.get(primaryId) ?? justSplitRecords[0] ?? null : null
};
this.emitChange();
return created;
@@ -428,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
@@ -462,8 +519,13 @@ export class LibraryStore extends EventTarget {
private refreshVisibleFiles(preferredId?: number | null, emit = true): void {
const { files, categoryFilter } = this.snapshot;
const availableIds = new Set(files.map((file) => file.id));
const justSplitIds = this.snapshot.justSplitFileIds.filter((id) => availableIds.has(id));
let visible: AudioFileSummary[];
if (categoryFilter === CATEGORY_FILTER_UNTAGGED) {
if (categoryFilter === CATEGORY_FILTER_JUST_SPLIT) {
const justSplitSet = new Set(justSplitIds);
visible = files.filter((file) => justSplitSet.has(file.id));
} else if (categoryFilter === CATEGORY_FILTER_UNTAGGED) {
visible = files.filter((file) => file.categories.length === 0);
} else if (categoryFilter) {
visible = files.filter((file) => file.categories.includes(categoryFilter));
@@ -500,7 +562,8 @@ export class LibraryStore extends EventTarget {
visibleFiles: visible,
selectedFileId: nextSelected,
selectedFileIds: nextSelectionSet,
focusedFile: nextFocused
focusedFile: nextFocused,
justSplitFileIds: justSplitIds
};
if (emit) {
@@ -518,7 +581,7 @@ export class LibraryStore extends EventTarget {
}
}
if (enforceVisible) {
return visible.at(0)?.id ?? null;
return visible[0]?.id ?? null;
}
return desiredId;
}

View File

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

View File

@@ -3,8 +3,11 @@ 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',
libraryDuplicates: 'library:duplicates',
libraryRename: 'library:rename',
libraryMove: 'library:move',
@@ -18,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'
@@ -37,8 +42,16 @@ 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. */
listDuplicates(): Promise<{ checksum: string; files: import('./models').AudioFileSummary[] }[]>;
/** Requests a rename for the target file. */
@@ -62,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. */
@@ -78,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 {

View File

@@ -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.
*/
@@ -138,4 +167,8 @@ export interface SplitSegmentRequest {
label?: string;
/** Metadata overrides to apply to the generated file. */
metadata?: SegmentMetadataInput;
/** Fade in duration in milliseconds. */
fadeInMs?: number;
/** Fade out duration in milliseconds. */
fadeOutMs?: number;
}

View File

@@ -1,433 +0,0 @@
/**
* Comprehensive test suite for DatabaseService.
* Run with: node --import tsx --test src/test/DatabaseService.test.ts
*/
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert';
import { DatabaseService } from '../main/services/DatabaseService';
import { getTempDbPath } from './testHelpers';
import fs from 'node:fs/promises';
describe('DatabaseService', () => {
let database: DatabaseService;
let dbPath: string;
beforeEach(() => {
dbPath = getTempDbPath();
database = new DatabaseService(dbPath);
database.initialize();
});
afterEach(async () => {
database.close();
await fs.unlink(dbPath).catch(() => {});
});
describe('file management', () => {
it('should upsert file record', () => {
const record = database.upsertFile({
absolutePath: '/test/file.wav',
relativePath: 'file.wav',
fileName: 'file.wav',
displayName: 'file',
modifiedAt: Date.now(),
createdAt: Date.now(),
size: 1024,
durationMs: 1000,
sampleRate: 44100,
bitDepth: 16,
checksum: 'abc123',
tags: ['test'],
categories: []
});
assert.ok(record.id);
assert.strictEqual(record.fileName, 'file.wav');
});
it('should list all files', () => {
database.upsertFile({
absolutePath: '/test/file1.wav',
relativePath: 'file1.wav',
fileName: 'file1.wav',
displayName: 'file1',
modifiedAt: Date.now(),
createdAt: Date.now(),
size: 1024,
durationMs: 1000,
sampleRate: 44100,
bitDepth: 16,
checksum: null,
tags: [],
categories: []
});
database.upsertFile({
absolutePath: '/test/file2.wav',
relativePath: 'file2.wav',
fileName: 'file2.wav',
displayName: 'file2',
modifiedAt: Date.now(),
createdAt: Date.now(),
size: 1024,
durationMs: 1000,
sampleRate: 44100,
bitDepth: 16,
checksum: null,
tags: [],
categories: []
});
const files = database.listFiles();
assert.strictEqual(files.length, 2);
});
it('should get file by id', () => {
const inserted = database.upsertFile({
absolutePath: '/test/file.wav',
relativePath: 'file.wav',
fileName: 'file.wav',
displayName: 'file',
modifiedAt: Date.now(),
createdAt: Date.now(),
size: 1024,
durationMs: 1000,
sampleRate: 44100,
bitDepth: 16,
checksum: null,
tags: [],
categories: []
});
const retrieved = database.getFileById(inserted.id);
assert.strictEqual(retrieved.id, inserted.id);
assert.strictEqual(retrieved.fileName, 'file.wav');
});
it('should update file location', () => {
const file = database.upsertFile({
absolutePath: '/test/file.wav',
relativePath: 'file.wav',
fileName: 'file.wav',
displayName: 'file',
modifiedAt: Date.now(),
createdAt: Date.now(),
size: 1024,
durationMs: 1000,
sampleRate: 44100,
bitDepth: 16,
checksum: null,
tags: [],
categories: []
});
const updated = database.updateFileLocation(
file.id,
'/test/newdir/file.wav',
'newdir/file.wav',
'file.wav',
'file'
);
assert.strictEqual(updated.absolutePath, '/test/newdir/file.wav');
assert.strictEqual(updated.relativePath, 'newdir/file.wav');
});
it('should delete file', () => {
const file = database.upsertFile({
absolutePath: '/test/file.wav',
relativePath: 'file.wav',
fileName: 'file.wav',
displayName: 'file',
modifiedAt: Date.now(),
createdAt: Date.now(),
size: 1024,
durationMs: 1000,
sampleRate: 44100,
bitDepth: 16,
checksum: null,
tags: [],
categories: []
});
database.deleteFile(file.id);
const files = database.listFiles();
assert.strictEqual(files.length, 0);
});
it('should remove files outside set', () => {
database.upsertFile({
absolutePath: '/test/file1.wav',
relativePath: 'file1.wav',
fileName: 'file1.wav',
displayName: 'file1',
modifiedAt: Date.now(),
createdAt: Date.now(),
size: 1024,
durationMs: 1000,
sampleRate: 44100,
bitDepth: 16,
checksum: null,
tags: [],
categories: []
});
database.upsertFile({
absolutePath: '/test/file2.wav',
relativePath: 'file2.wav',
fileName: 'file2.wav',
displayName: 'file2',
modifiedAt: Date.now(),
createdAt: Date.now(),
size: 1024,
durationMs: 1000,
sampleRate: 44100,
bitDepth: 16,
checksum: null,
tags: [],
categories: []
});
const kept = new Set(['/test/file1.wav']);
const removed = database.removeFilesOutside(kept);
assert.strictEqual(removed, 1);
assert.strictEqual(database.listFiles().length, 1);
});
});
describe('tagging', () => {
it('should update file tags', () => {
const file = database.upsertFile({
absolutePath: '/test/file.wav',
relativePath: 'file.wav',
fileName: 'file.wav',
displayName: 'file',
modifiedAt: Date.now(),
createdAt: Date.now(),
size: 1024,
durationMs: 1000,
sampleRate: 44100,
bitDepth: 16,
checksum: null,
tags: [],
categories: []
});
const updated = database.updateTagging(file.id, ['tag1', 'tag2'], ['cat1']);
assert.deepStrictEqual(updated.tags, ['tag1', 'tag2']);
assert.deepStrictEqual(updated.categories, ['cat1']);
});
it('should update custom name', () => {
const file = database.upsertFile({
absolutePath: '/test/file.wav',
relativePath: 'file.wav',
fileName: 'file.wav',
displayName: 'file',
modifiedAt: Date.now(),
createdAt: Date.now(),
size: 1024,
durationMs: 1000,
sampleRate: 44100,
bitDepth: 16,
checksum: null,
tags: [],
categories: []
});
const updated = database.updateCustomName(file.id, 'Custom Name');
assert.strictEqual(updated.customName, 'Custom Name');
});
it('should clear custom name', () => {
const file = database.upsertFile({
absolutePath: '/test/file.wav',
relativePath: 'file.wav',
fileName: 'file.wav',
displayName: 'file',
modifiedAt: Date.now(),
createdAt: Date.now(),
size: 1024,
durationMs: 1000,
sampleRate: 44100,
bitDepth: 16,
checksum: null,
tags: [],
categories: []
});
database.updateCustomName(file.id, 'Custom Name');
const updated = database.updateCustomName(file.id, null);
assert.strictEqual(updated.customName, null);
});
});
describe('categories', () => {
it('should upsert category', () => {
database.upsertCategory({
id: 'TEST',
category: 'TEST CATEGORY',
subCategory: 'Test Sub',
shortCode: 'TST',
explanation: 'Test explanation',
synonyms: ['test', 'sample']
});
const categories = database.listCategories();
assert.ok(categories.some(c => c.id === 'TEST'));
});
it('should get category by id', () => {
database.upsertCategory({
id: 'TEST',
category: 'TEST CATEGORY',
subCategory: 'Test Sub',
shortCode: 'TST',
explanation: 'Test explanation',
synonyms: ['test', 'sample']
});
const category = database.getCategoryById('TEST');
assert.ok(category);
assert.strictEqual(category.id, 'TEST');
assert.strictEqual(category.shortCode, 'TST');
});
it('should list all categories', () => {
database.upsertCategory({
id: 'TEST1',
category: 'TEST CATEGORY',
subCategory: 'Test Sub 1',
shortCode: 'TS1',
explanation: 'Test',
synonyms: []
});
database.upsertCategory({
id: 'TEST2',
category: 'TEST CATEGORY',
subCategory: 'Test Sub 2',
shortCode: 'TS2',
explanation: 'Test',
synonyms: []
});
const categories = database.listCategories();
assert.ok(categories.length >= 2);
});
});
describe('duplicates', () => {
it('should detect duplicate files by checksum', () => {
const checksum = 'duplicate-checksum';
database.upsertFile({
absolutePath: '/test/file1.wav',
relativePath: 'file1.wav',
fileName: 'file1.wav',
displayName: 'file1',
modifiedAt: Date.now(),
createdAt: Date.now(),
size: 1024,
durationMs: 1000,
sampleRate: 44100,
bitDepth: 16,
checksum,
tags: [],
categories: []
});
database.upsertFile({
absolutePath: '/test/file2.wav',
relativePath: 'file2.wav',
fileName: 'file2.wav',
displayName: 'file2',
modifiedAt: Date.now(),
createdAt: Date.now(),
size: 1024,
durationMs: 1000,
sampleRate: 44100,
bitDepth: 16,
checksum,
tags: [],
categories: []
});
const duplicates = database.listDuplicateGroups();
assert.strictEqual(duplicates.length, 1);
assert.strictEqual(duplicates[0].checksum, checksum);
assert.strictEqual(duplicates[0].files.length, 2);
});
it('should not include unique files in duplicates', () => {
database.upsertFile({
absolutePath: '/test/file1.wav',
relativePath: 'file1.wav',
fileName: 'file1.wav',
displayName: 'file1',
modifiedAt: Date.now(),
createdAt: Date.now(),
size: 1024,
durationMs: 1000,
sampleRate: 44100,
bitDepth: 16,
checksum: 'unique1',
tags: [],
categories: []
});
database.upsertFile({
absolutePath: '/test/file2.wav',
relativePath: 'file2.wav',
fileName: 'file2.wav',
displayName: 'file2',
modifiedAt: Date.now(),
createdAt: Date.now(),
size: 1024,
durationMs: 1000,
sampleRate: 44100,
bitDepth: 16,
checksum: 'unique2',
tags: [],
categories: []
});
const duplicates = database.listDuplicateGroups();
assert.strictEqual(duplicates.length, 0);
});
});
describe('settings', () => {
it('should save and retrieve library path setting', () => {
database.setSetting('libraryPath', '/test/library');
const settings = database.getSettings();
assert.strictEqual(settings.libraryPath, '/test/library');
});
it('should return null for missing library path', () => {
const settings = database.getSettings();
assert.strictEqual(settings.libraryPath, null);
});
it('should overwrite existing setting', () => {
database.setSetting('libraryPath', '/path1');
database.setSetting('libraryPath', '/path2');
const settings = database.getSettings();
assert.strictEqual(settings.libraryPath, '/path2');
});
it('should handle custom settings', () => {
database.setSetting('customKey', { test: 'value' });
// Settings are stored as JSON strings internally
// Just verify no errors thrown
assert.ok(true);
});
});
});

View File

@@ -1,389 +0,0 @@
/**
* Comprehensive test suite for LibraryService.
* Run with: node --import tsx --test src/test/LibraryService.test.ts
*/
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert';
import { LibraryService } from '../main/services/LibraryService';
import { DatabaseService } from '../main/services/DatabaseService';
import { SettingsService } from '../main/services/SettingsService';
import { TagService } from '../main/services/TagService';
import { SearchService } from '../main/services/SearchService';
import { TestWavGenerator, getTempDbPath, getTempLibraryPath } from './testHelpers';
import fs from 'node:fs/promises';
import path from 'node:path';
describe('LibraryService', () => {
let libraryService: LibraryService;
let database: DatabaseService;
let settings: SettingsService;
let tagService: TagService;
let searchService: SearchService;
let dbPath: string;
let libraryPath: string;
beforeEach(async () => {
dbPath = getTempDbPath();
libraryPath = getTempLibraryPath();
database = new DatabaseService(dbPath);
database.initialize();
settings = new SettingsService(database);
tagService = new TagService(database);
searchService = new SearchService(database, tagService);
libraryService = new LibraryService(database, settings, tagService, searchService);
// Set library path in database
database.setSetting('libraryPath', libraryPath);
// Create test files
await TestWavGenerator.createTestLibrary(libraryPath, [
{
relativePath: 'test1.wav',
options: {
duration: 1,
author: 'Author 1',
copyright: 'Copyright 1',
title: 'Test 1',
rating: 3
}
},
{
relativePath: 'subfolder/test2.wav',
options: {
duration: 2,
author: 'Author 2',
copyright: 'Copyright 2',
title: 'Test 2',
rating: 5
}
}
]);
});
afterEach(async () => {
database.close();
await TestWavGenerator.cleanupTestLibrary(libraryPath);
await fs.unlink(dbPath).catch(() => {});
});
describe('scanLibrary', () => {
it('should discover WAV files in library', async () => {
const result = await libraryService.scanLibrary();
assert.strictEqual(result.added, 2);
assert.strictEqual(result.total, 2);
});
it('should read embedded metadata during scan', async () => {
await libraryService.scanLibrary();
const files = libraryService.listFiles();
const file1 = files.find(f => f.fileName === 'test1.wav');
assert.ok(file1);
assert.strictEqual(file1.customName, 'Test 1');
});
it('should detect updated files on rescan', async () => {
await libraryService.scanLibrary();
// Modify a file
const testFile = path.join(libraryPath, 'test1.wav');
await TestWavGenerator.writeTestWav(testFile, {
duration: 1,
author: 'Modified Author'
});
const result = await libraryService.scanLibrary();
assert.strictEqual(result.updated, 2); // Both files are "updated" on rescan
});
it('should remove deleted files from database', async () => {
await libraryService.scanLibrary();
// Delete a file
await fs.unlink(path.join(libraryPath, 'test1.wav'));
const result = await libraryService.scanLibrary();
assert.strictEqual(result.removed, 1);
assert.strictEqual(result.total, 1);
});
it('should clean up orphaned temp files', async () => {
// Create a temp file
const tempFile = path.join(libraryPath, 'test.1234567890-1-abc123.tmp');
await fs.writeFile(tempFile, 'temp data');
await libraryService.scanLibrary();
// Temp file should be cleaned up
const tempExists = await fs.access(tempFile).then(() => true).catch(() => false);
assert.strictEqual(tempExists, false);
});
});
describe('listFiles', () => {
it('should return all files in library', async () => {
await libraryService.scanLibrary();
const files = libraryService.listFiles();
assert.strictEqual(files.length, 2);
});
it('should include file metadata', async () => {
await libraryService.scanLibrary();
const files = libraryService.listFiles();
const file = files[0];
assert.ok(file.absolutePath);
assert.ok(file.fileName);
assert.ok(file.relativePath);
assert.ok(typeof file.size === 'number');
assert.ok(typeof file.durationMs === 'number');
});
});
describe('renameFile', () => {
it('should rename a file', async () => {
await libraryService.scanLibrary();
const files = libraryService.listFiles();
const file = files[0];
const updated = await libraryService.renameFile(file.id, 'renamed.wav');
assert.strictEqual(updated.fileName, 'renamed.wav');
assert.ok(updated.absolutePath.endsWith('renamed.wav'));
});
it('should update file on disk', async () => {
await libraryService.scanLibrary();
const files = libraryService.listFiles();
const file = files[0];
const originalPath = file.absolutePath;
const updated = await libraryService.renameFile(file.id, 'renamed.wav');
const originalExists = await fs.access(originalPath).then(() => true).catch(() => false);
const newExists = await fs.access(updated.absolutePath).then(() => true).catch(() => false);
assert.strictEqual(originalExists, false);
assert.strictEqual(newExists, true);
});
});
describe('moveFile', () => {
it('should move file to different directory', async () => {
await libraryService.scanLibrary();
const files = libraryService.listFiles();
const file = files.find(f => f.fileName === 'test1.wav');
assert.ok(file);
const updated = await libraryService.moveFile(file.id, 'newdir');
assert.ok(updated.relativePath.includes('newdir'));
});
it('should create target directory if needed', async () => {
await libraryService.scanLibrary();
const files = libraryService.listFiles();
const file = files[0];
await libraryService.moveFile(file.id, 'new/nested/dir');
const dirExists = await fs.access(path.join(libraryPath, 'new/nested/dir'))
.then(() => true).catch(() => false);
assert.strictEqual(dirExists, true);
});
});
describe('updateCustomName', () => {
it('should update custom name in database', async () => {
await libraryService.scanLibrary();
const files = libraryService.listFiles();
const file = files[0];
const updated = libraryService.updateCustomName(file.id, 'Custom Name');
assert.strictEqual(updated.customName, 'Custom Name');
});
it('should allow clearing custom name', async () => {
await libraryService.scanLibrary();
const files = libraryService.listFiles();
const file = files[0];
libraryService.updateCustomName(file.id, 'Custom Name');
const updated = libraryService.updateCustomName(file.id, null);
assert.strictEqual(updated.customName, null);
});
});
describe('updateFileMetadata', () => {
it('should update author metadata', async () => {
await libraryService.scanLibrary();
const files = libraryService.listFiles();
const file = files[0];
await libraryService.updateFileMetadata(file.id, {
author: 'New Author'
});
const metadata = await libraryService.readFileMetadata(file.id);
assert.strictEqual(metadata.author, 'New Author');
});
it('should update copyright metadata', async () => {
await libraryService.scanLibrary();
const files = libraryService.listFiles();
const file = files[0];
await libraryService.updateFileMetadata(file.id, {
copyright: 'New Copyright'
});
const metadata = await libraryService.readFileMetadata(file.id);
assert.strictEqual(metadata.copyright, 'New Copyright');
});
it('should update rating metadata', async () => {
await libraryService.scanLibrary();
const files = libraryService.listFiles();
const file = files[0];
await libraryService.updateFileMetadata(file.id, {
rating: 4
});
const metadata = await libraryService.readFileMetadata(file.id);
assert.strictEqual(metadata.rating, 4);
});
});
describe('readFileMetadata', () => {
it('should read embedded metadata', async () => {
await libraryService.scanLibrary();
const files = libraryService.listFiles();
const file = files[0];
const metadata = await libraryService.readFileMetadata(file.id);
assert.ok(metadata);
assert.ok(metadata.author);
});
});
describe('listMetadataSuggestions', () => {
it('should aggregate authors from library', async () => {
await libraryService.scanLibrary();
const suggestions = await libraryService.listMetadataSuggestions();
assert.ok(suggestions.authors.length > 0);
assert.ok(suggestions.authors.includes('Author 1'));
assert.ok(suggestions.authors.includes('Author 2'));
});
it('should aggregate copyrights from library', async () => {
await libraryService.scanLibrary();
const suggestions = await libraryService.listMetadataSuggestions();
assert.ok(suggestions.copyrights.length > 0);
assert.ok(suggestions.copyrights.includes('Copyright 1'));
assert.ok(suggestions.copyrights.includes('Copyright 2'));
});
it('should return sorted suggestions', async () => {
await libraryService.scanLibrary();
const suggestions = await libraryService.listMetadataSuggestions();
// Check if arrays are sorted
const authorsSorted = [...suggestions.authors].sort();
assert.deepStrictEqual(suggestions.authors, authorsSorted);
const copyrightsSorted = [...suggestions.copyrights].sort();
assert.deepStrictEqual(suggestions.copyrights, copyrightsSorted);
});
});
describe('deleteFiles', () => {
it('should delete file from disk', async () => {
await libraryService.scanLibrary();
const files = libraryService.listFiles();
const file = files[0];
const filePath = file.absolutePath;
await libraryService.deleteFiles([file.id]);
const exists = await fs.access(filePath).then(() => true).catch(() => false);
assert.strictEqual(exists, false);
});
it('should remove file from database', async () => {
await libraryService.scanLibrary();
const files = libraryService.listFiles();
const file = files[0];
await libraryService.deleteFiles([file.id]);
const remaining = libraryService.listFiles();
assert.strictEqual(remaining.length, 1);
assert.ok(!remaining.find(f => f.id === file.id));
});
it('should handle multiple files', async () => {
await libraryService.scanLibrary();
const files = libraryService.listFiles();
const ids = files.map(f => f.id);
await libraryService.deleteFiles(ids);
const remaining = libraryService.listFiles();
assert.strictEqual(remaining.length, 0);
});
});
describe('cleanupTempFiles', () => {
it('should remove orphaned temp files', async () => {
const tempFile = path.join(libraryPath, 'test.1234567890-1-abc123.tmp');
await fs.writeFile(tempFile, 'temp');
const cleaned = await libraryService.cleanupTempFiles();
assert.strictEqual(cleaned, 1);
const exists = await fs.access(tempFile).then(() => true).catch(() => false);
assert.strictEqual(exists, false);
});
it('should recover temp files when final file missing', async () => {
const finalPath = path.join(libraryPath, 'test.wav');
const tempFile = `${finalPath}.1234567890-1-abc123.tmp`;
await fs.writeFile(tempFile, 'temp data');
const cleaned = await libraryService.cleanupTempFiles();
assert.strictEqual(cleaned, 1);
const finalExists = await fs.access(finalPath).then(() => true).catch(() => false);
assert.strictEqual(finalExists, true);
});
});
});

View File

@@ -1,232 +0,0 @@
/**
* Comprehensive test suite for SearchService.
* Run with: node --import tsx --test src/test/SearchService.test.ts
*/
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert';
import { SearchService } from '../main/services/SearchService';
import { DatabaseService } from '../main/services/DatabaseService';
import { TagService } from '../main/services/TagService';
import { TestWavGenerator, getTempDbPath, getTempLibraryPath } from './testHelpers';
import fs from 'node:fs/promises';
import path from 'node:path';
describe('SearchService', () => {
let searchService: SearchService;
let database: DatabaseService;
let tagService: TagService;
let dbPath: string;
let libraryPath: string;
beforeEach(async () => {
dbPath = getTempDbPath();
libraryPath = getTempLibraryPath();
database = new DatabaseService(dbPath);
database.initialize();
tagService = new TagService(database);
searchService = new SearchService(database, tagService);
// Create test files with various metadata
await TestWavGenerator.createTestLibrary(libraryPath, [
{
relativePath: 'ambience/city/traffic.wav',
options: {
author: 'John Doe',
copyright: 'Copyright 2024',
title: 'City Traffic',
tags: ['traffic', 'city', 'urban']
}
},
{
relativePath: 'ambience/nature/forest.wav',
options: {
author: 'Jane Smith',
copyright: 'Copyright 2024',
title: 'Forest Ambience',
tags: ['forest', 'nature', 'birds']
}
},
{
relativePath: 'effects/explosion.wav',
options: {
author: 'John Doe',
copyright: 'Copyright 2023',
title: 'Big Explosion',
tags: ['explosion', 'boom', 'loud']
}
}
]);
// Add files to database
const files = [
{
absolutePath: path.join(libraryPath, 'ambience/city/traffic.wav'),
relativePath: 'ambience/city/traffic.wav',
fileName: 'traffic.wav',
displayName: 'traffic',
tags: ['traffic', 'city', 'urban'],
categories: []
},
{
absolutePath: path.join(libraryPath, 'ambience/nature/forest.wav'),
relativePath: 'ambience/nature/forest.wav',
fileName: 'forest.wav',
displayName: 'forest',
tags: ['forest', 'nature', 'birds'],
categories: []
},
{
absolutePath: path.join(libraryPath, 'effects/explosion.wav'),
relativePath: 'effects/explosion.wav',
fileName: 'explosion.wav',
displayName: 'explosion',
tags: ['explosion', 'boom', 'loud'],
categories: []
}
];
for (const file of files) {
database.upsertFile({
...file,
modifiedAt: Date.now(),
createdAt: Date.now(),
size: 1024,
durationMs: 1000,
sampleRate: 44100,
bitDepth: 16,
checksum: null
});
}
searchService.rebuildIndex();
});
afterEach(async () => {
database.close();
await TestWavGenerator.cleanupTestLibrary(libraryPath);
await fs.unlink(dbPath).catch(() => {});
});
describe('search', () => {
it('should find files by filename', () => {
const results = searchService.search('traffic');
assert.ok(results.length > 0);
assert.ok(results.some(r => r.fileName === 'traffic.wav'));
});
it('should find files by tag', () => {
const results = searchService.search('forest');
assert.ok(results.length > 0);
assert.ok(results.some(r => r.tags.includes('forest')));
});
it('should find files by path', () => {
const results = searchService.search('ambience');
assert.ok(results.length >= 2);
assert.ok(results.every(r => r.relativePath.includes('ambience')));
});
it('should return empty array for no matches', () => {
const results = searchService.search('nonexistent');
assert.strictEqual(results.length, 0);
});
it('should perform fuzzy matching', () => {
const results = searchService.search('trffic'); // typo
assert.ok(results.length > 0);
assert.ok(results.some(r => r.fileName === 'traffic.wav'));
});
it('should support author: filter', () => {
const results = searchService.search('author:john');
assert.ok(results.length > 0);
assert.ok(results.every(r => {
const metadata = tagService.readMetadata(r.absolutePath);
return metadata.author?.toLowerCase().includes('john');
}));
});
it('should support copyright: filter', () => {
const results = searchService.search('copyright:2024');
assert.ok(results.length >= 2);
});
it('should combine regular search with filters', () => {
const results = searchService.search('author:john explosion');
assert.ok(results.length > 0);
assert.ok(results.some(r => r.fileName === 'explosion.wav'));
});
it('should cache metadata for performance', () => {
// First search should cache metadata
searchService.search('author:john');
// Second search should use cache
const results = searchService.search('author:john');
assert.ok(results.length > 0);
});
it('should handle empty query', () => {
const results = searchService.search('');
// Empty query returns all files
assert.strictEqual(results.length, 3);
});
it('should handle whitespace-only query', () => {
const results = searchService.search(' ');
// Whitespace-only query is treated as empty, returns all files
assert.strictEqual(results.length, 3);
});
});
describe('rebuildIndex', () => {
it('should update search index', () => {
// Add a new file to database
database.upsertFile({
absolutePath: path.join(libraryPath, 'new.wav'),
relativePath: 'new.wav',
fileName: 'new.wav',
displayName: 'new',
modifiedAt: Date.now(),
createdAt: Date.now(),
size: 1024,
durationMs: 1000,
sampleRate: 44100,
bitDepth: 16,
checksum: null,
tags: ['new'],
categories: []
});
searchService.rebuildIndex();
const results = searchService.search('new');
assert.ok(results.length > 0);
assert.ok(results.some(r => r.fileName === 'new.wav'));
});
it('should clear metadata cache', () => {
// Cache some metadata
searchService.search('author:john');
// Rebuild should clear cache
searchService.rebuildIndex();
// Search again should work
const results = searchService.search('author:john');
assert.ok(results.length > 0);
});
});
});

View File

@@ -1,69 +0,0 @@
/**
* Test Suite Summary
* ===================
*
* Comprehensive test coverage for AudioSort application core services.
* All tests use real WAV file generation and isolated test environments.
*
* Total Test Count: 67+ tests across 4 test files
*
* Test Files:
* -----------
*
* 1. DatabaseService.test.ts (20 tests)
* - File CRUD operations
* - Tag and category management
* - Duplicate detection
* - Settings persistence
*
* 2. TagService.test.ts (14 tests)
* - WAV metadata reading (author, copyright, title, rating)
* - WAV metadata writing
* - Tag normalization and application
*
* 3. LibraryService.test.ts (20 tests)
* - Library scanning with metadata priority
* - File operations (rename, move, delete)
* - Temp file cleanup and recovery
* - Metadata management without organizing
*
* 4. SearchService.test.ts (13 tests)
* - Fuzzy search across filename, tags, path
* - Advanced filters (author:, copyright:)
* - Typo tolerance and similarity scoring
* - Metadata caching and index rebuilding
*
* Running Tests:
* --------------
*
* # Run all tests
* npm test
*
* # Run specific service tests
* npm run test:database
* npm run test:tag
* npm run test:library
* npm run test:search
*
* Test Infrastructure:
* --------------------
*
* - Framework: Node.js built-in test runner (node:test)
* - TypeScript: tsx for runtime execution
* - WAV Generation: WaveFile library with real audio data
* - Test Isolation: Temporary databases and libraries per suite
* - Audio Format: 16-bit PCM, 44100 Hz, sine wave (440 Hz)
* - Metadata: WAV INFO chunks (INAM, IART, ICOP, IRTD, etc.)
*
* Key Features:
* -------------
*
* ✓ Real WAV file generation with embedded metadata
* ✓ Isolated test environments (no cross-contamination)
* ✓ Comprehensive coverage of all service methods
* ✓ Edge case testing (missing data, errors, duplicates)
* ✓ Fast execution with minimal dependencies
* ✓ No mocking - tests use actual service implementations
*
* For detailed documentation, see TEST_README.md
*/

View File

@@ -1,271 +0,0 @@
/**
* Comprehensive test suite for TagService.
* Run with: node --import tsx --test src/test/TagService.test.ts
*/
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert';
import { TagService } from '../main/services/TagService';
import { DatabaseService } from '../main/services/DatabaseService';
import { TestWavGenerator, getTempDbPath, getTempLibraryPath } from './testHelpers';
import fs from 'node:fs/promises';
import path from 'node:path';
describe('TagService', () => {
let tagService: TagService;
let database: DatabaseService;
let dbPath: string;
let libraryPath: string;
let testFilePath: string;
beforeEach(async () => {
dbPath = getTempDbPath();
libraryPath = getTempLibraryPath();
database = new DatabaseService(dbPath);
database.initialize();
tagService = new TagService(database);
await TestWavGenerator.createTestLibrary(libraryPath, [
{
relativePath: 'test.wav',
options: {
duration: 1,
author: 'Test Author',
copyright: 'Test Copyright',
title: 'Test Title',
rating: 3,
tags: ['test', 'audio'],
categories: ['AMBNatr']
}
}
]);
testFilePath = path.join(libraryPath, 'test.wav');
// Add file to database
database.upsertFile({
absolutePath: testFilePath,
relativePath: 'test.wav',
fileName: 'test.wav',
displayName: 'test',
modifiedAt: Date.now(),
createdAt: Date.now(),
size: 1024,
durationMs: 1000,
sampleRate: 44100,
bitDepth: 16,
checksum: 'test-checksum',
tags: ['test'],
categories: ['AMBNatr']
});
});
afterEach(async () => {
database.close();
await TestWavGenerator.cleanupTestLibrary(libraryPath);
await fs.unlink(dbPath).catch(() => {});
});
describe('readMetadata', () => {
it('should read author from WAV file', () => {
const metadata = tagService.readMetadata(testFilePath);
assert.strictEqual(metadata.author, 'Test Author');
});
it('should read copyright from WAV file', () => {
const metadata = tagService.readMetadata(testFilePath);
assert.strictEqual(metadata.copyright, 'Test Copyright');
});
it('should read title from WAV file', () => {
const metadata = tagService.readMetadata(testFilePath);
assert.strictEqual(metadata.title, 'Test Title');
});
it('should read rating from WAV file', () => {
const metadata = tagService.readMetadata(testFilePath);
assert.strictEqual(metadata.rating, 3);
});
it('should handle missing metadata gracefully', async () => {
const emptyFilePath = path.join(libraryPath, 'empty.wav');
await TestWavGenerator.writeTestWav(emptyFilePath, {});
const metadata = tagService.readMetadata(emptyFilePath);
assert.strictEqual(metadata.author, undefined);
assert.strictEqual(metadata.copyright, undefined);
assert.strictEqual(metadata.title, undefined);
assert.strictEqual(metadata.rating, undefined);
});
it('should handle corrupted files gracefully', () => {
const metadata = tagService.readMetadata('/nonexistent/file.wav');
assert.deepStrictEqual(metadata, {});
});
});
describe('writeMetadataOnly', () => {
it('should write author to WAV file', () => {
tagService.writeMetadataOnly(testFilePath, {
tags: ['test'],
categories: ['AMBNatr'],
author: 'New Author'
});
const metadata = tagService.readMetadata(testFilePath);
assert.strictEqual(metadata.author, 'New Author');
});
it('should write copyright to WAV file', () => {
tagService.writeMetadataOnly(testFilePath, {
tags: ['test'],
categories: ['AMBNatr'],
copyright: 'New Copyright'
});
const metadata = tagService.readMetadata(testFilePath);
assert.strictEqual(metadata.copyright, 'New Copyright');
});
it('should write title to WAV file', () => {
tagService.writeMetadataOnly(testFilePath, {
tags: ['test'],
categories: ['AMBNatr'],
title: 'New Title'
});
const metadata = tagService.readMetadata(testFilePath);
assert.strictEqual(metadata.title, 'New Title');
});
it('should write rating to WAV file', () => {
tagService.writeMetadataOnly(testFilePath, {
tags: ['test'],
categories: ['AMBNatr'],
rating: 5
});
const metadata = tagService.readMetadata(testFilePath);
assert.strictEqual(metadata.rating, 5);
});
it('should clear metadata when set to empty', () => {
tagService.writeMetadataOnly(testFilePath, {
tags: ['test'],
categories: ['AMBNatr'],
author: '',
copyright: '',
title: '',
rating: 0
});
const metadata = tagService.readMetadata(testFilePath);
assert.strictEqual(metadata.author, undefined);
assert.strictEqual(metadata.copyright, undefined);
assert.strictEqual(metadata.title, undefined);
assert.strictEqual(metadata.rating, undefined);
});
it('should write tags to WAV file', () => {
tagService.writeMetadataOnly(testFilePath, {
tags: ['new', 'tags'],
categories: ['AMBNatr']
});
// Metadata write succeeded without throwing
assert.ok(true);
});
it('should write categories to WAV file', () => {
tagService.writeMetadataOnly(testFilePath, {
tags: ['test'],
categories: ['AMBCity', 'AMBNatr']
});
// Verify write succeeded
assert.ok(true);
});
it('should embed parent id in INFO chunk', () => {
tagService.writeMetadataOnly(testFilePath, {
tags: ['test'],
categories: ['AMBNatr'],
parentId: 42
});
const info = tagService.readInfoTags(testFilePath);
assert.strictEqual(info.IPAR, '42');
const comment = info.ICMT ?? '';
assert.ok(comment.length > 0, 'Expected INFO comment to be populated with JSON payload');
const parsed = JSON.parse(comment);
assert.strictEqual(parsed.parentId, 42);
assert.deepStrictEqual(parsed.categories, ['AMBNatr']);
});
});
describe('applyTagging', () => {
it('should update tags in database', () => {
const files = database.listFiles();
const fileId = files[0].id;
const updated = tagService.applyTagging(fileId, ['new', 'tags'], ['AMBNatr']);
assert.deepStrictEqual(updated.tags, ['new', 'tags']);
});
it('should update categories in database', () => {
const files = database.listFiles();
const fileId = files[0].id;
const updated = tagService.applyTagging(fileId, ['test'], ['AMBCity', 'AMBNatr']);
assert.deepStrictEqual(updated.categories, ['AMBCity', 'AMBNatr']);
});
it('should write metadata to WAV file', () => {
const files = database.listFiles();
const fileId = files[0].id;
tagService.applyTagging(fileId, ['new', 'tags'], ['AMBCity']);
// Verify it didn't throw
assert.ok(true);
});
it('should normalize tags by trimming whitespace', () => {
const files = database.listFiles();
const fileId = files[0].id;
const updated = tagService.applyTagging(fileId, [' tag1 ', ' tag2 '], ['AMBNatr']);
assert.deepStrictEqual(updated.tags, ['tag1', 'tag2']);
});
it('should remove duplicate tags', () => {
const files = database.listFiles();
const fileId = files[0].id;
const updated = tagService.applyTagging(fileId, ['tag1', 'tag1', 'tag2'], ['AMBNatr']);
assert.deepStrictEqual(updated.tags, ['tag1', 'tag2']);
});
it('should remove empty tags', () => {
const files = database.listFiles();
const fileId = files[0].id;
const updated = tagService.applyTagging(fileId, ['tag1', '', ' ', 'tag2'], ['AMBNatr']);
assert.deepStrictEqual(updated.tags, ['tag1', 'tag2']);
});
it('should preserve existing tags when omitted', () => {
const files = database.listFiles();
const fileId = files[0].id;
const before = database.getFileById(fileId);
const updated = tagService.applyTagging(fileId, undefined, ['AMBNatr']);
assert.deepStrictEqual(updated.tags, before.tags);
});
});
});

View File

@@ -1,152 +0,0 @@
/**
* Test helpers and utilities for creating test WAV files with metadata.
*/
import { WaveFile } from 'wavefile';
import fs from 'node:fs/promises';
import path from 'node:path';
export class TestWavGenerator {
/**
* Creates a simple 1-second WAV file with metadata.
*/
public static createTestWav(options: {
duration?: number;
sampleRate?: number;
bitDepth?: number;
tags?: string[];
categories?: string[];
author?: string;
copyright?: string;
title?: string;
rating?: number;
} = {}): Buffer {
const {
duration = 1,
sampleRate = 44100,
bitDepth = 16,
tags = [],
categories = [],
author,
copyright,
title,
rating
} = options;
// Generate simple sine wave data
const numSamples = Math.floor(sampleRate * duration);
const frequency = 440; // A4 note
const samples = new Int16Array(numSamples);
const amplitude = Math.pow(2, bitDepth - 1) - 1;
for (let i = 0; i < numSamples; i++) {
const time = i / sampleRate;
samples[i] = Math.floor(amplitude * 0.5 * Math.sin(2 * Math.PI * frequency * time));
}
// Create WAV file
const wav = new WaveFile();
wav.fromScratch(1, sampleRate, String(bitDepth), samples);
// Add metadata using INFO chunks
const wavWithTags = wav as WaveFile & {
setTag?: (tag: string, value: string) => void;
};
if (wavWithTags.setTag) {
// Set tags and categories
if (tags.length > 0) {
wavWithTags.setTag('IKEY', tags.join('; '));
wavWithTags.setTag('ISBJ', tags.join('; '));
}
if (categories.length > 0) {
wavWithTags.setTag('IGNR', categories.join('; '));
wavWithTags.setTag('ICMT', categories.join('; '));
wavWithTags.setTag('ISUB', categories.join('; '));
}
// Set metadata
if (title) {
wavWithTags.setTag('INAM', title);
}
if (author) {
wavWithTags.setTag('IART', author);
}
if (copyright) {
wavWithTags.setTag('ICOP', copyright);
}
if (rating && rating > 0) {
wavWithTags.setTag('IRTD', String(rating * 2));
}
wavWithTags.setTag('ISFT', 'AudioSort Test Generator');
}
return Buffer.from(wav.toBuffer());
}
/**
* Writes a test WAV file to disk.
*/
public static async writeTestWav(
filePath: string,
options: Parameters<typeof TestWavGenerator.createTestWav>[0] = {}
): Promise<void> {
const buffer = this.createTestWav(options);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, buffer);
}
/**
* Creates multiple test WAV files in a directory structure.
*/
public static async createTestLibrary(
rootPath: string,
files: Array<{
relativePath: string;
options?: Parameters<typeof TestWavGenerator.createTestWav>[0];
}>
): Promise<void> {
await fs.mkdir(rootPath, { recursive: true });
for (const file of files) {
const fullPath = path.join(rootPath, file.relativePath);
await this.writeTestWav(fullPath, file.options);
}
}
/**
* Cleans up a test directory.
*/
public static async cleanupTestLibrary(rootPath: string): Promise<void> {
try {
await fs.rm(rootPath, { recursive: true, force: true });
} catch (error) {
console.warn(`Failed to cleanup test library at ${rootPath}:`, error);
}
}
}
/**
* Creates a temporary test database path.
*/
export function getTempDbPath(): string {
return path.join(process.cwd(), 'test-data', `test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
}
/**
* Creates a temporary test library path.
*/
export function getTempLibraryPath(): string {
return path.join(process.cwd(), 'test-data', `test-lib-${Date.now()}-${Math.random().toString(36).slice(2)}`);
}
/**
* Delays execution for testing async operations.
*/
export function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

BIN
test.wav

Binary file not shown.