mirror of
https://github.com/litruv/AudioSort.git
synced 2026-07-27 04:06:02 +10:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 18a2d3ee26 | |||
| cf6c14c161 | |||
| 524bbdce8a | |||
| 3569a46ebb | |||
| b4bfbd3ef6 | |||
| c33371b206 | |||
| 4639494b9d | |||
| 7308e3c97c | |||
| 5b2090c102 | |||
| e2017fc6ea | |||
| e9c679dfaf | |||
| d0f7b0fe18 | |||
| db1db50302 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -64,3 +64,4 @@ test-data/
|
|||||||
*.tmp
|
*.tmp
|
||||||
*.temp
|
*.temp
|
||||||
.cache/
|
.cache/
|
||||||
|
AudioSort.code-workspace
|
||||||
|
|||||||
53
README.md
53
README.md
@@ -1,7 +1,7 @@
|
|||||||
# AudioSort
|
# AudioSort
|
||||||
[](https://github.com/litruv/AudioSort/actions/workflows/build.yml)
|
[](https://github.com/litruv/AudioSort/actions/workflows/build.yml)
|
||||||
|
|
||||||
A desktop application for organizing, tagging, and managing WAV audio files with Universal Category System (UCS) support.
|
A desktop application for organizing, tagging, and managing WAV audio files with Universal Category System (UCS) support, featuring advanced waveform editing and audio splitting capabilities.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -16,6 +16,7 @@ A desktop application for organizing, tagging, and managing WAV audio files with
|
|||||||
### Powerful Search & Organization
|
### Powerful Search & Organization
|
||||||
- Fuzzy search across filenames, tags, categories, and metadata
|
- Fuzzy search across filenames, tags, categories, and metadata
|
||||||
- Filter by category or view untagged files
|
- Filter by category or view untagged files
|
||||||
|
- Quick "JUST SPLIT" filter surfaces freshly generated segments for rapid QC
|
||||||
- Multi-select support for batch operations
|
- Multi-select support for batch operations
|
||||||
- Custom naming with automatic conflict resolution
|
- Custom naming with automatic conflict resolution
|
||||||
|
|
||||||
@@ -27,27 +28,24 @@ A desktop application for organizing, tagging, and managing WAV audio files with
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
### Beautiful Interface
|
### Beautiful Interface
|
||||||
- Real-time waveform visualization with dynamic color-coding based on RMS levels
|
- 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
|
- Smooth animations and polished drag-and-drop interactions
|
||||||
- Responsive file list with keyboard navigation (Arrow keys, Ctrl+A)
|
- Responsive file list with keyboard navigation (Arrow keys, Ctrl+A)
|
||||||
- Dark theme optimized for long sessions
|
- Dark theme optimized for long sessions
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
### 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
|
## Installation
|
||||||
|
|
||||||
@@ -119,8 +117,8 @@ AudioSort/
|
|||||||
│ │ └── hooks/ # Custom React hooks
|
│ │ └── hooks/ # Custom React hooks
|
||||||
│ ├── preload/ # Electron preload scripts
|
│ ├── preload/ # Electron preload scripts
|
||||||
│ └── shared/ # Shared types and IPC definitions
|
│ └── shared/ # Shared types and IPC definitions
|
||||||
├── data/ # User data directory (created at runtime)
|
├── data/
|
||||||
├── UCS.csv # Universal Category System catalog
|
│ └── UCS.csv # Universal Category System catalog
|
||||||
└── repoimages/ # Documentation assets
|
└── repoimages/ # Documentation assets
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -128,22 +126,12 @@ AudioSort/
|
|||||||
|
|
||||||
### Scripts
|
### Scripts
|
||||||
```bash
|
```bash
|
||||||
npm run dev # Start development server with hot reload
|
npm run dev # Start development server with hot reload
|
||||||
npm run build # Build renderer and main process
|
npm run build # Build renderer and main process
|
||||||
npm run lint # Type-check with TypeScript
|
npm run lint # Type-check with TypeScript
|
||||||
npm run test # Run test suite
|
npm run pack # Package without installer
|
||||||
npm run pack # Package without installer
|
npm run dist # Create distributable
|
||||||
npm run dist # Create distributable
|
npm run dist:win # Create Windows installer
|
||||||
npm run dist:win # Create Windows installer
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
@@ -158,4 +146,3 @@ Edit `package.json` `build` section to customize:
|
|||||||
### Database Location
|
### Database Location
|
||||||
User data is stored in:
|
User data is stored in:
|
||||||
- **Windows**: `%APPDATA%/AudioSort/`
|
- **Windows**: `%APPDATA%/AudioSort/`
|
||||||
- **Linux**: `~/.config/AudioSort/`
|
|
||||||
|
|||||||
219
TEST_README.md
219
TEST_README.md
@@ -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
BIN
build/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
56
package.json
56
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audio-sort",
|
"name": "audio-sort",
|
||||||
"version": "0.2.0",
|
"version": "0.3.2",
|
||||||
"description": "Electron audio sorting application with fuzzy search, tagging, library management, and advanced waveform editing with audio splitting capabilities for WAV files.",
|
"description": "Electron audio sorting application with fuzzy search, tagging, library management, and advanced waveform editing with audio splitting capabilities for WAV files.",
|
||||||
"main": "dist/main/main/index.js",
|
"main": "dist/main/main/index.js",
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
@@ -17,49 +17,49 @@
|
|||||||
"pack": "npm run build && electron-builder --dir",
|
"pack": "npm run build && electron-builder --dir",
|
||||||
"dist": "npm run build && electron-builder",
|
"dist": "npm run build && electron-builder",
|
||||||
"dist:win": "npm run build && electron-builder --win",
|
"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",
|
"postinstall": "patch-package"
|
||||||
"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"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^11.0.0",
|
"better-sqlite3": "^12.4.1",
|
||||||
"csv-parse": "^5.5.5",
|
"csv-parse": "^6.1.0",
|
||||||
"fast-glob": "^3.3.2",
|
"fast-glob": "^3.3.3",
|
||||||
"fuse.js": "^6.6.2",
|
"fuse.js": "^7.1.0",
|
||||||
"music-metadata": "^10.5.0",
|
"music-metadata": "^11.10.0",
|
||||||
"react": "^18.3.1",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^19.2.0",
|
||||||
|
"standardized-audio-context": "^25.3.77",
|
||||||
"wavefile": "^11.0.0"
|
"wavefile": "^11.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.8",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/node": "^20.12.7",
|
"@types/node": "^24.10.0",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^19.2.2",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^19.2.2",
|
||||||
"@vitejs/plugin-react": "^4.3.3",
|
"@vitejs/plugin-react": "^5.1.0",
|
||||||
"concurrently": "^8.2.2",
|
"concurrently": "^9.2.1",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^10.1.0",
|
||||||
"electron": "^32.0.0",
|
"electron": "^39.1.2",
|
||||||
"electron-builder": "^26.0.12",
|
"electron-builder": "^26.0.12",
|
||||||
|
"patch-package": "^8.0.1",
|
||||||
"tree-kill": "^1.2.2",
|
"tree-kill": "^1.2.2",
|
||||||
"tsx": "^4.15.7",
|
"tsx": "^4.20.6",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^5.1.6"
|
"vite": "^7.2.2"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "com.audiosort.app",
|
"appId": "com.audiosort.app",
|
||||||
"productName": "AudioSort",
|
"productName": "AudioSort",
|
||||||
"files": [
|
"files": [
|
||||||
"dist/**/*",
|
"dist/**/*",
|
||||||
"UCS.csv"
|
"data/**/*"
|
||||||
],
|
],
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "release"
|
"output": "release"
|
||||||
},
|
},
|
||||||
"win": {
|
"win": {
|
||||||
"target": ["nsis"],
|
"target": [
|
||||||
|
"nsis"
|
||||||
|
],
|
||||||
"icon": "build/icon.ico"
|
"icon": "build/icon.ico"
|
||||||
},
|
},
|
||||||
"nsis": {
|
"nsis": {
|
||||||
@@ -68,8 +68,8 @@
|
|||||||
},
|
},
|
||||||
"extraResources": [
|
"extraResources": [
|
||||||
{
|
{
|
||||||
"from": "UCS.csv",
|
"from": "data/UCS.csv",
|
||||||
"to": "UCS.csv"
|
"to": "data/UCS.csv"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
13
patches/better-sqlite3+12.4.1.patch
Normal file
13
patches/better-sqlite3+12.4.1.patch
Normal 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
BIN
repoimages/editor.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 236 KiB |
@@ -41,7 +41,7 @@ export class MainApp {
|
|||||||
this.searchService
|
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.ensureCategoriesLoaded(catalogPath);
|
||||||
await this.libraryService.scanLibrary();
|
await this.libraryService.scanLibrary();
|
||||||
|
|
||||||
@@ -225,6 +225,9 @@ export class MainApp {
|
|||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.libraryScan, async () => this.requireLibrary().scanLibrary());
|
ipcMain.handle(IPC_CHANNELS.libraryScan, async () => this.requireLibrary().scanLibrary());
|
||||||
ipcMain.handle(IPC_CHANNELS.libraryList, async () => this.requireLibrary().listFiles());
|
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.libraryDuplicates, async () => this.requireLibrary().listDuplicates());
|
||||||
ipcMain.handle(IPC_CHANNELS.searchQuery, async (_event: IpcMainInvokeEvent, query: string) =>
|
ipcMain.handle(IPC_CHANNELS.searchQuery, async (_event: IpcMainInvokeEvent, query: string) =>
|
||||||
this.requireSearch().search(query)
|
this.requireSearch().search(query)
|
||||||
|
|||||||
@@ -12,6 +12,15 @@ import { SearchService } from './SearchService';
|
|||||||
import { WaveFile } from 'wavefile';
|
import { WaveFile } from 'wavefile';
|
||||||
import { OrganizationService } from './OrganizationService';
|
import { OrganizationService } from './OrganizationService';
|
||||||
|
|
||||||
|
type MusicMetadataParser = (path: string, options?: { duration?: boolean }) => Promise<{
|
||||||
|
format: { duration?: number | null; sampleRate?: number | null; bitsPerSample?: number | null };
|
||||||
|
common: { comment?: unknown[]; genre?: unknown[]; subtitle?: unknown };
|
||||||
|
}>;
|
||||||
|
|
||||||
|
type MusicMetadataNamespace = Partial<{ parseFile: MusicMetadataParser }> & {
|
||||||
|
default?: Partial<{ parseFile: MusicMetadataParser }>;
|
||||||
|
};
|
||||||
|
|
||||||
interface CsvCategoryRow {
|
interface CsvCategoryRow {
|
||||||
Category: string;
|
Category: string;
|
||||||
SubCategory: string;
|
SubCategory: string;
|
||||||
@@ -31,6 +40,8 @@ export class LibraryService {
|
|||||||
private readonly organization: OrganizationService;
|
private readonly organization: OrganizationService;
|
||||||
private metadataSuggestionCache: { authors: Set<string> } | null = null;
|
private metadataSuggestionCache: { authors: Set<string> } | null = null;
|
||||||
private readonly waveformPreviewCache = new Map<number, { modifiedAt: number; pointCount: number; samples: number[]; rms: number }>();
|
private readonly waveformPreviewCache = new Map<number, { modifiedAt: number; pointCount: number; samples: number[]; rms: number }>();
|
||||||
|
private offlineAudioContextCtor: (new (channelCount: number, length: number, sampleRate: number) => any) | null = null;
|
||||||
|
private musicMetadataParseFile: MusicMetadataParser | null = null;
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly database: DatabaseService,
|
private readonly database: DatabaseService,
|
||||||
private readonly settings: SettingsService,
|
private readonly settings: SettingsService,
|
||||||
@@ -83,7 +94,7 @@ export class LibraryService {
|
|||||||
this.resetMetadataSuggestionsCache();
|
this.resetMetadataSuggestionsCache();
|
||||||
this.waveformPreviewCache.clear();
|
this.waveformPreviewCache.clear();
|
||||||
|
|
||||||
const cleanedTempFiles = await this.cleanupTempFiles();
|
await this.cleanupTempFiles();
|
||||||
const libraryRoot = this.settings.ensureLibraryPath();
|
const libraryRoot = this.settings.ensureLibraryPath();
|
||||||
const existing = this.database.listFiles();
|
const existing = this.database.listFiles();
|
||||||
const existingByPath = new Map(existing.map((file) => [file.absolutePath, file] as const));
|
const existingByPath = new Map(existing.map((file) => [file.absolutePath, file] as const));
|
||||||
@@ -213,6 +224,20 @@ export class LibraryService {
|
|||||||
return this.database.listFiles();
|
return this.database.listFiles();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to resolve a file summary by id. Returns null when the record no longer exists.
|
||||||
|
*/
|
||||||
|
public getFileById(fileId: number): AudioFileSummary | null {
|
||||||
|
try {
|
||||||
|
return this.database.getFileById(fileId);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
console.warn(`Failed to resolve file by id ${fileId}:`, error.message);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns groups of files that have identical checksums (potential duplicates).
|
* Returns groups of files that have identical checksums (potential duplicates).
|
||||||
*/
|
*/
|
||||||
@@ -344,7 +369,7 @@ export class LibraryService {
|
|||||||
const buffer = await fs.readFile(record.absolutePath);
|
const buffer = await fs.readFile(record.absolutePath);
|
||||||
const wave = new WaveFile(buffer);
|
const wave = new WaveFile(buffer);
|
||||||
const sampleBlock = wave.getSamples(false, Float64Array) as Float64Array | Float64Array[];
|
const sampleBlock = wave.getSamples(false, Float64Array) as Float64Array | Float64Array[];
|
||||||
const channels = Array.isArray(sampleBlock) ? sampleBlock : [sampleBlock];
|
const channels = (Array.isArray(sampleBlock) ? sampleBlock : [sampleBlock]) as Float64Array[];
|
||||||
const firstChannel = channels[0];
|
const firstChannel = channels[0];
|
||||||
|
|
||||||
if (!firstChannel || firstChannel.length === 0) {
|
if (!firstChannel || firstChannel.length === 0) {
|
||||||
@@ -751,7 +776,9 @@ export class LibraryService {
|
|||||||
startMs,
|
startMs,
|
||||||
endMs: safeEnd,
|
endMs: safeEnd,
|
||||||
label: segment.label?.trim() ?? undefined,
|
label: segment.label?.trim() ?? undefined,
|
||||||
metadata: segment.metadata
|
metadata: segment.metadata,
|
||||||
|
fadeInMs: Number.isFinite(segment.fadeInMs) ? Math.max(0, Math.floor(segment.fadeInMs!)) : 0,
|
||||||
|
fadeOutMs: Number.isFinite(segment.fadeOutMs) ? Math.max(0, Math.floor(segment.fadeOutMs!)) : 0
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((segment) => segment.endMs - segment.startMs >= 5)
|
.filter((segment) => segment.endMs - segment.startMs >= 5)
|
||||||
@@ -780,7 +807,14 @@ export class LibraryService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const segmentChannels = channels.map((channel) => channel.slice(startSample, endSample));
|
let segmentChannels: Float64Array[] = channels.map((channel) => channel.slice(startSample, endSample));
|
||||||
|
|
||||||
|
const fadeInMs = segment.fadeInMs ?? 0;
|
||||||
|
const fadeOutMs = segment.fadeOutMs ?? 0;
|
||||||
|
if (fadeInMs > 0 || fadeOutMs > 0) {
|
||||||
|
segmentChannels = (await this.applyFadeEnvelopeToSegment(segmentChannels, sampleRate, fadeInMs, fadeOutMs)) as Float64Array[];
|
||||||
|
}
|
||||||
|
|
||||||
const nextWave = new WaveFile();
|
const nextWave = new WaveFile();
|
||||||
nextWave.fromScratch(channels.length, sampleRate, bitDepthText, segmentChannels.length === 1 ? segmentChannels[0] : segmentChannels);
|
nextWave.fromScratch(channels.length, sampleRate, bitDepthText, segmentChannels.length === 1 ? segmentChannels[0] : segmentChannels);
|
||||||
(nextWave as WaveFile & { container?: string }).container = container;
|
(nextWave as WaveFile & { container?: string }).container = container;
|
||||||
@@ -902,6 +936,152 @@ export class LibraryService {
|
|||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the configured fade envelope to the provided channel data. Tries to render using
|
||||||
|
* `standardized-audio-context` for consistency with the editor preview and falls back to
|
||||||
|
* a manual smooth-step implementation if the dependency is unavailable at runtime.
|
||||||
|
*/
|
||||||
|
private async applyFadeEnvelopeToSegment(
|
||||||
|
source: Float64Array[],
|
||||||
|
sampleRate: number,
|
||||||
|
fadeInMs: number,
|
||||||
|
fadeOutMs: number
|
||||||
|
): Promise<Float64Array[]> {
|
||||||
|
if (source.length === 0 || source[0]?.length === 0) {
|
||||||
|
return source.map((channel) => channel.slice());
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalSamples = source[0].length;
|
||||||
|
const fadeInSamples = Math.min(totalSamples, Math.max(0, Math.floor((fadeInMs / 1000) * sampleRate)));
|
||||||
|
const fadeOutSamples = Math.min(totalSamples, Math.max(0, Math.floor((fadeOutMs / 1000) * sampleRate)));
|
||||||
|
|
||||||
|
if (fadeInSamples === 0 && fadeOutSamples === 0) {
|
||||||
|
return source.map((channel) => channel.slice());
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyManual = (): Float64Array[] => {
|
||||||
|
return source.map((channel) => {
|
||||||
|
const copy = channel.slice();
|
||||||
|
const sampleCount = copy.length;
|
||||||
|
const fadeInDenominator = Math.max(1, fadeInSamples - 1);
|
||||||
|
const fadeOutDenominator = Math.max(1, fadeOutSamples - 1);
|
||||||
|
for (let index = 0; index < sampleCount; index += 1) {
|
||||||
|
let gain = 1;
|
||||||
|
if (fadeInSamples > 0 && index < fadeInSamples) {
|
||||||
|
const t = fadeInDenominator > 0 ? index / fadeInDenominator : 0;
|
||||||
|
const eased = this.smoothStep(t);
|
||||||
|
gain = Math.min(gain, eased);
|
||||||
|
}
|
||||||
|
if (fadeOutSamples > 0) {
|
||||||
|
const fromEnd = sampleCount - 1 - index;
|
||||||
|
if (fromEnd < fadeOutSamples) {
|
||||||
|
const t = fadeOutDenominator > 0 ? fromEnd / fadeOutDenominator : 0;
|
||||||
|
const eased = this.smoothStep(t);
|
||||||
|
gain = Math.min(gain, eased);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
copy[index] = copy[index] * gain;
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
let OfflineContextCtor = this.offlineAudioContextCtor;
|
||||||
|
if (!OfflineContextCtor) {
|
||||||
|
const audioModule = (await import('standardized-audio-context')) as {
|
||||||
|
OfflineAudioContext?: new (channelCount: number, length: number, sampleRate: number) => any;
|
||||||
|
default?: { OfflineAudioContext?: new (channelCount: number, length: number, sampleRate: number) => any };
|
||||||
|
};
|
||||||
|
const resolvedCtor = audioModule.OfflineAudioContext ?? audioModule.default?.OfflineAudioContext ?? null;
|
||||||
|
if (typeof resolvedCtor !== 'function') {
|
||||||
|
return applyManual();
|
||||||
|
}
|
||||||
|
this.offlineAudioContextCtor = resolvedCtor;
|
||||||
|
OfflineContextCtor = resolvedCtor;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelCount = source.length;
|
||||||
|
const offlineContext = new OfflineContextCtor(channelCount, totalSamples, sampleRate);
|
||||||
|
const buffer = offlineContext.createBuffer(channelCount, totalSamples, sampleRate);
|
||||||
|
for (let channelIndex = 0; channelIndex < channelCount; channelIndex += 1) {
|
||||||
|
const channelData = buffer.getChannelData(channelIndex);
|
||||||
|
const sourceChannel = source[channelIndex];
|
||||||
|
for (let sampleIndex = 0; sampleIndex < totalSamples; sampleIndex += 1) {
|
||||||
|
channelData[sampleIndex] = sourceChannel?.[sampleIndex] ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bufferSource = offlineContext.createBufferSource();
|
||||||
|
bufferSource.buffer = buffer;
|
||||||
|
const gainNode = offlineContext.createGain();
|
||||||
|
bufferSource.connect(gainNode);
|
||||||
|
gainNode.connect(offlineContext.destination);
|
||||||
|
|
||||||
|
const totalDurationSeconds = totalSamples / sampleRate;
|
||||||
|
gainNode.gain.setValueAtTime(1, 0);
|
||||||
|
|
||||||
|
if (fadeInSamples > 0) {
|
||||||
|
const fadeInCurve = this.generateFadeCurve(Math.max(fadeInSamples, 2), 'in');
|
||||||
|
gainNode.gain.setValueAtTime(fadeInCurve[0], 0);
|
||||||
|
gainNode.gain.setValueCurveAtTime(fadeInCurve, 0, fadeInSamples / sampleRate);
|
||||||
|
gainNode.gain.setValueAtTime(1, fadeInSamples / sampleRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fadeOutSamples > 0) {
|
||||||
|
const fadeOutCurve = this.generateFadeCurve(Math.max(fadeOutSamples, 2), 'out');
|
||||||
|
const fadeOutStart = Math.max(0, totalDurationSeconds - fadeOutSamples / sampleRate);
|
||||||
|
gainNode.gain.setValueAtTime(1, fadeOutStart);
|
||||||
|
gainNode.gain.setValueCurveAtTime(fadeOutCurve, fadeOutStart, fadeOutSamples / sampleRate);
|
||||||
|
gainNode.gain.setValueAtTime(fadeOutCurve[fadeOutCurve.length - 1], totalDurationSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
bufferSource.start(0);
|
||||||
|
const renderedBuffer = await offlineContext.startRendering();
|
||||||
|
const processed: Float64Array[] = [];
|
||||||
|
for (let channelIndex = 0; channelIndex < channelCount; channelIndex += 1) {
|
||||||
|
const renderedChannel = renderedBuffer.getChannelData(channelIndex);
|
||||||
|
const clone = new Float64Array(renderedChannel.length);
|
||||||
|
clone.set(renderedChannel);
|
||||||
|
processed.push(clone);
|
||||||
|
}
|
||||||
|
return processed;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Falling back to manual fade processing', error);
|
||||||
|
return applyManual();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a smooth fade curve using a cubic smooth-step easing function.
|
||||||
|
*/
|
||||||
|
private generateFadeCurve(sampleCount: number, direction: 'in' | 'out'): Float32Array {
|
||||||
|
const totalSamples = Math.max(sampleCount, 2);
|
||||||
|
const curve = new Float32Array(totalSamples);
|
||||||
|
const lastIndex = totalSamples - 1;
|
||||||
|
for (let index = 0; index < totalSamples; index += 1) {
|
||||||
|
const t = lastIndex === 0 ? 1 : index / lastIndex;
|
||||||
|
const eased = this.smoothStep(Math.min(Math.max(t, 0), 1));
|
||||||
|
curve[index] = direction === 'in' ? eased : 1 - eased;
|
||||||
|
}
|
||||||
|
if (direction === 'in') {
|
||||||
|
curve[0] = 0;
|
||||||
|
curve[lastIndex] = 1;
|
||||||
|
} else {
|
||||||
|
curve[0] = 1;
|
||||||
|
curve[lastIndex] = 0;
|
||||||
|
}
|
||||||
|
return curve;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the cubic smooth-step easing value for the provided normalised progress.
|
||||||
|
*/
|
||||||
|
private smoothStep(t: number): number {
|
||||||
|
const clamped = Math.min(Math.max(t, 0), 1);
|
||||||
|
return clamped * clamped * (3 - 2 * clamped);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the previously parsed UCS category catalog.
|
* Returns the previously parsed UCS category catalog.
|
||||||
*/
|
*/
|
||||||
@@ -1172,6 +1352,29 @@ export class LibraryService {
|
|||||||
return value.replace(/\\/g, '/');
|
return value.replace(/\\/g, '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves and caches the music-metadata parseFile implementation.
|
||||||
|
*/
|
||||||
|
private async resolveMusicMetadataParser(): Promise<MusicMetadataParser> {
|
||||||
|
if (this.musicMetadataParseFile) {
|
||||||
|
return this.musicMetadataParseFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
const namespace = (await import('music-metadata')) as MusicMetadataNamespace;
|
||||||
|
const candidate = typeof namespace.parseFile === 'function'
|
||||||
|
? namespace.parseFile
|
||||||
|
: namespace.default && typeof namespace.default.parseFile === 'function'
|
||||||
|
? namespace.default.parseFile
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!candidate) {
|
||||||
|
throw new Error('music-metadata parseFile not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.musicMetadataParseFile = candidate;
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts metadata from the WAV container, falling back to defaults when parsing fails.
|
* Extracts metadata from the WAV container, falling back to defaults when parsing fails.
|
||||||
*/
|
*/
|
||||||
@@ -1183,22 +1386,8 @@ export class LibraryService {
|
|||||||
categories: string[];
|
categories: string[];
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const mmImport = await import('music-metadata');
|
const parseFile = await this.resolveMusicMetadataParser();
|
||||||
const mm = (mmImport as { default?: unknown }).default ?? mmImport;
|
const metadata = await parseFile(filePath, { duration: true });
|
||||||
|
|
||||||
const loadMusicMetadata = typeof mm === 'object' && mm !== null && 'loadMusicMetadata' in mm
|
|
||||||
? (mm as { loadMusicMetadata: () => Promise<{ parseFile: (path: string, opts?: { duration?: boolean }) => Promise<{
|
|
||||||
format: { duration?: number | null; sampleRate?: number | null; bitsPerSample?: number | null };
|
|
||||||
common: { comment?: unknown[]; genre?: unknown[]; subtitle?: unknown };
|
|
||||||
}> }> }).loadMusicMetadata
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (!loadMusicMetadata) {
|
|
||||||
throw new Error('music-metadata loadMusicMetadata not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const musicMetadata = await loadMusicMetadata();
|
|
||||||
const metadata = await musicMetadata.parseFile(filePath, { duration: true });
|
|
||||||
const infoTags = this.tagService.readInfoTags(filePath);
|
const infoTags = this.tagService.readInfoTags(filePath);
|
||||||
const splitInfoValues = (input: string | undefined): string[] =>
|
const splitInfoValues = (input: string | undefined): string[] =>
|
||||||
input
|
input
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import Fuse from 'fuse.js';
|
import Fuse, { type FuseResult, type IFuseOptions } from 'fuse.js';
|
||||||
import { AudioFileSummary } from '../../shared/models';
|
import { AudioFileSummary } from '../../shared/models';
|
||||||
import { DatabaseService } from './DatabaseService';
|
import { DatabaseService } from './DatabaseService';
|
||||||
import { TagService } from './TagService';
|
import { TagService } from './TagService';
|
||||||
@@ -76,7 +76,7 @@ export class SearchService {
|
|||||||
|
|
||||||
return fuseInstance
|
return fuseInstance
|
||||||
.search(remainingQuery)
|
.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)));
|
.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.
|
* Provides the standard Fuse configuration used by the service.
|
||||||
*/
|
*/
|
||||||
private createFuseOptions(): Fuse.IFuseOptions<AudioFileSummary> {
|
private createFuseOptions(): IFuseOptions<AudioFileSummary> {
|
||||||
return {
|
return {
|
||||||
includeScore: true,
|
includeScore: true,
|
||||||
threshold: 0.35,
|
threshold: 0.35,
|
||||||
@@ -114,7 +114,7 @@ export class SearchService {
|
|||||||
{ name: 'tags', weight: 0.2 },
|
{ name: 'tags', weight: 0.2 },
|
||||||
{ name: 'categories', weight: 0.1 }
|
{ name: 'categories', weight: 0.1 }
|
||||||
]
|
]
|
||||||
} satisfies Fuse.IFuseOptions<AudioFileSummary>;
|
} satisfies IFuseOptions<AudioFileSummary>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -163,8 +163,8 @@ export class TagService {
|
|||||||
.filter((entry) => entry.length > 0);
|
.filter((entry) => entry.length > 0);
|
||||||
|
|
||||||
const tagText = tagValuesList.length > 0 ? tagValuesList.join('; ') : null;
|
const tagText = tagValuesList.length > 0 ? tagValuesList.join('; ') : null;
|
||||||
const categoryText = categoryValuesList.length > 0 ? categoryValuesList.join('; ') : null;
|
const categoryText = categoryValuesList.length > 0 ? categoryValuesList.join('; ') : null;
|
||||||
const primaryCategory = categoryValuesList.at(0) ?? null;
|
const primaryCategory = categoryValuesList.length > 0 ? categoryValuesList[0] : null;
|
||||||
const trimmedTitle = metadata.title?.toString().trim();
|
const trimmedTitle = metadata.title?.toString().trim();
|
||||||
const effectiveTitle = trimmedTitle && trimmedTitle.length > 0 ? trimmedTitle : null;
|
const effectiveTitle = trimmedTitle && trimmedTitle.length > 0 ? trimmedTitle : null;
|
||||||
const trimmedAuthor = metadata.author?.toString().trim();
|
const trimmedAuthor = metadata.author?.toString().trim();
|
||||||
|
|||||||
10
src/main/types/standardized-audio-context.d.ts
vendored
Normal file
10
src/main/types/standardized-audio-context.d.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,10 @@ const api: RendererApi = {
|
|||||||
async listAudioFiles(): Promise<AudioFileSummary[]> {
|
async listAudioFiles(): Promise<AudioFileSummary[]> {
|
||||||
return ipcRenderer.invoke(IPC_CHANNELS.libraryList);
|
return ipcRenderer.invoke(IPC_CHANNELS.libraryList);
|
||||||
},
|
},
|
||||||
|
/** 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[] }[]> {
|
async listDuplicates(): Promise<{ checksum: string; files: AudioFileSummary[] }[]> {
|
||||||
return ipcRenderer.invoke(IPC_CHANNELS.libraryDuplicates);
|
return ipcRenderer.invoke(IPC_CHANNELS.libraryDuplicates);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState, type JSX } from 'react';
|
||||||
import FileList from './components/FileList';
|
import FileList from './components/FileList';
|
||||||
import FileDetailPanel from './components/FileDetailPanel';
|
import FileDetailPanel from './components/FileDetailPanel';
|
||||||
import MultiFileEditor from './components/MultiFileEditor';
|
import MultiFileEditor from './components/MultiFileEditor';
|
||||||
@@ -69,7 +69,7 @@ function App(): JSX.Element {
|
|||||||
[library.files, library.selectedFileId]
|
[library.files, library.selectedFileId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const parentFile = useMemo(() => {
|
const parentFileFromStore = useMemo(() => {
|
||||||
const parentId = selectedFile?.parentFileId ?? null;
|
const parentId = selectedFile?.parentFileId ?? null;
|
||||||
if (parentId === null) {
|
if (parentId === null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -77,6 +77,39 @@ function App(): JSX.Element {
|
|||||||
return library.files.find((file) => file.id === parentId) ?? null;
|
return library.files.find((file) => file.id === parentId) ?? null;
|
||||||
}, [library.files, selectedFile?.parentFileId]);
|
}, [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(
|
const selectedFiles = useMemo(
|
||||||
() => library.files.filter((file) => library.selectedFileIds.has(file.id)),
|
() => library.files.filter((file) => library.selectedFileIds.has(file.id)),
|
||||||
[library.files, library.selectedFileIds]
|
[library.files, library.selectedFileIds]
|
||||||
@@ -241,8 +274,7 @@ function App(): JSX.Element {
|
|||||||
setActiveTab('listen');
|
setActiveTab('listen');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditModeSplitComplete = async (created: AudioFileSummary[]) => {
|
const handleEditModeSplitComplete = (created: AudioFileSummary[]) => {
|
||||||
await libraryStore.rescan();
|
|
||||||
setActiveTab('listen');
|
setActiveTab('listen');
|
||||||
if (created.length > 0) {
|
if (created.length > 0) {
|
||||||
libraryStore.selectFile(created[0].id);
|
libraryStore.selectFile(created[0].id);
|
||||||
@@ -261,6 +293,7 @@ function App(): JSX.Element {
|
|||||||
categories={library.categories}
|
categories={library.categories}
|
||||||
files={library.files}
|
files={library.files}
|
||||||
activeFilter={library.categoryFilter}
|
activeFilter={library.categoryFilter}
|
||||||
|
justSplitIds={library.justSplitFileIds}
|
||||||
onSelect={handleCategorySelect}
|
onSelect={handleCategorySelect}
|
||||||
onDropFiles={handleDropFilesToCategory}
|
onDropFiles={handleDropFilesToCategory}
|
||||||
/>
|
/>
|
||||||
@@ -307,7 +340,7 @@ function App(): JSX.Element {
|
|||||||
<>
|
<>
|
||||||
<FileDetailPanel
|
<FileDetailPanel
|
||||||
file={selectedFile}
|
file={selectedFile}
|
||||||
parentFile={parentFile}
|
parentFile={resolvedParentFile}
|
||||||
categories={library.categories}
|
categories={library.categories}
|
||||||
onRename={handleRename}
|
onRename={handleRename}
|
||||||
onMove={handleMove}
|
onMove={handleMove}
|
||||||
|
|||||||
BIN
src/renderer/src/assets/icon.ico
Normal file
BIN
src/renderer/src/assets/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
BIN
src/renderer/src/assets/icon.png
Normal file
BIN
src/renderer/src/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 190 KiB |
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import type { AudioFileSummary, CategoryRecord } from '../../../shared/models';
|
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 {
|
import {
|
||||||
buildCategorySwatch,
|
buildCategorySwatch,
|
||||||
createCategoryStyleVars,
|
createCategoryStyleVars,
|
||||||
@@ -11,6 +11,8 @@ export interface CategorySidebarProps {
|
|||||||
categories: CategoryRecord[];
|
categories: CategoryRecord[];
|
||||||
files: AudioFileSummary[];
|
files: AudioFileSummary[];
|
||||||
activeFilter: CategoryFilterValue;
|
activeFilter: CategoryFilterValue;
|
||||||
|
/** Identifiers of files created during the most recent split action. */
|
||||||
|
justSplitIds: number[];
|
||||||
onSelect(filter: CategoryFilterValue): void;
|
onSelect(filter: CategoryFilterValue): void;
|
||||||
onDropFiles?(fileIds: number[], categoryId: string): 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 { total, untagged, categoryCounts } = deriveCounts(files);
|
||||||
const swatchMap = useMemo(() => {
|
const swatchMap = useMemo(() => {
|
||||||
const map = new Map<string, ReturnType<typeof buildCategorySwatch>>();
|
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__label">TO TAG</span>
|
||||||
<span className="category-sidebar__count">{untagged}</span>
|
<span className="category-sidebar__count">{untagged}</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
{sortedGroups.map(([groupName, records]) => {
|
{sortedGroups.map(([groupName, records]) => {
|
||||||
const sortedRecords = records
|
const sortedRecords = records
|
||||||
|
|||||||
@@ -369,7 +369,7 @@ export function FileDetailPanel({ file, parentFile, categories, onRename, onMove
|
|||||||
onClick={handleOpenParent}
|
onClick={handleOpenParent}
|
||||||
title={parentFile ? `Open source file ${parentFile.displayName}` : 'Open source file'}
|
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>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -189,7 +189,39 @@ export function EditModePanel({ file, categories, onClose, onSplitComplete }: Ed
|
|||||||
const playbackLengthMs = absoluteEndMs - playbackStartMs;
|
const playbackLengthMs = absoluteEndMs - playbackStartMs;
|
||||||
const source = context.createBufferSource();
|
const source = context.createBufferSource();
|
||||||
source.buffer = buffer;
|
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);
|
source.start(0, playbackStartMs / 1000, playbackLengthMs / 1000);
|
||||||
|
|
||||||
playbackInfoRef.current = { ...request };
|
playbackInfoRef.current = { ...request };
|
||||||
@@ -230,7 +262,7 @@ export function EditModePanel({ file, categories, onClose, onSplitComplete }: Ed
|
|||||||
stopPlaybackCursorTracking();
|
stopPlaybackCursorTracking();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[ensureAudioContext, loadAudioBuffer, stopActiveSource, schedulePlaybackCursorUpdate, stopPlaybackCursorTracking]
|
[ensureAudioContext, loadAudioBuffer, stopActiveSource, schedulePlaybackCursorUpdate, stopPlaybackCursorTracking, segments]
|
||||||
);
|
);
|
||||||
|
|
||||||
const pausePlayback = useCallback(() => {
|
const pausePlayback = useCallback(() => {
|
||||||
@@ -403,7 +435,9 @@ export function EditModePanel({ file, categories, onClose, onSplitComplete }: Ed
|
|||||||
endMs: end,
|
endMs: end,
|
||||||
label: `Segment ${segments.length + 1}`,
|
label: `Segment ${segments.length + 1}`,
|
||||||
metadata: buildDefaultMetadata(file, baseMetadata),
|
metadata: buildDefaultMetadata(file, baseMetadata),
|
||||||
color: generateSegmentColor(segments.length)
|
color: generateSegmentColor(segments.length),
|
||||||
|
fadeInMs: 0,
|
||||||
|
fadeOutMs: 0
|
||||||
};
|
};
|
||||||
setSegments((current) => {
|
setSegments((current) => {
|
||||||
const next = [...current, newSegment].sort((a, b) => a.startMs - b.startMs);
|
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) => {
|
const handleRemoveSegment = useCallback((segmentId: string) => {
|
||||||
setSegments((current) => current.filter((segment) => segment.id !== segmentId));
|
setSegments((current) => current.filter((segment) => segment.id !== segmentId));
|
||||||
setSelectedSegmentId((current) => (current === segmentId ? null : current));
|
setSelectedSegmentId((current) => (current === segmentId ? null : current));
|
||||||
@@ -570,6 +614,7 @@ export function EditModePanel({ file, categories, onClose, onSplitComplete }: Ed
|
|||||||
onResizeSegment={handleResizeSegment}
|
onResizeSegment={handleResizeSegment}
|
||||||
onPlayFromCursor={playFromCursor}
|
onPlayFromCursor={playFromCursor}
|
||||||
playbackCursorMs={playbackCursorMs}
|
playbackCursorMs={playbackCursorMs}
|
||||||
|
onUpdateFade={handleUpdateFade}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export interface WaveformEditorCanvasProps {
|
|||||||
onPlayFromCursor(startMs: number): void;
|
onPlayFromCursor(startMs: number): void;
|
||||||
/** Location of the active playback cursor relative to the source audio, if playing. */
|
/** Location of the active playback cursor relative to the source audio, if playing. */
|
||||||
playbackCursorMs: number | null;
|
playbackCursorMs: number | null;
|
||||||
|
/** Invoked when fade in/out values change. */
|
||||||
|
onUpdateFade(segmentId: string, fadeInMs: number, fadeOutMs: number): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DraftSelection {
|
interface DraftSelection {
|
||||||
@@ -32,7 +34,8 @@ type DragState =
|
|||||||
| { type: 'none' }
|
| { type: 'none' }
|
||||||
| { type: 'creating'; anchorMs: number; pointerMs: number }
|
| { type: 'creating'; anchorMs: number; pointerMs: number }
|
||||||
| { type: 'resizing'; segmentId: string; handle: 'start' | 'end' }
|
| { 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_SEGMENT_MS = 50;
|
||||||
const MIN_VIEWPORT_MS = 200;
|
const MIN_VIEWPORT_MS = 200;
|
||||||
@@ -53,7 +56,8 @@ export function WaveformEditorCanvas({
|
|||||||
onCreateSegment,
|
onCreateSegment,
|
||||||
onResizeSegment,
|
onResizeSegment,
|
||||||
onPlayFromCursor,
|
onPlayFromCursor,
|
||||||
playbackCursorMs
|
playbackCursorMs,
|
||||||
|
onUpdateFade
|
||||||
}: WaveformEditorCanvasProps): JSX.Element {
|
}: WaveformEditorCanvasProps): JSX.Element {
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
const dragStateRef = useRef<DragState>({ type: 'none' });
|
const dragStateRef = useRef<DragState>({ type: 'none' });
|
||||||
@@ -179,13 +183,121 @@ export function WaveformEditorCanvas({
|
|||||||
}
|
}
|
||||||
context.fillRect(startX, 0, endX - startX, height);
|
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
|
// Use segment color for handles
|
||||||
context.fillStyle = isSelected ? '#ffcc00' : color;
|
context.fillStyle = isSelected ? '#ffcc00' : color;
|
||||||
context.fillRect(startX - HANDLE_WIDTH_PX / 2, 0, HANDLE_WIDTH_PX, height);
|
context.fillRect(startX - HANDLE_WIDTH_PX / 2, 0, HANDLE_WIDTH_PX, height);
|
||||||
context.fillRect(endX - HANDLE_WIDTH_PX / 2, 0, HANDLE_WIDTH_PX, height);
|
context.fillRect(endX - HANDLE_WIDTH_PX / 2, 0, HANDLE_WIDTH_PX, height);
|
||||||
|
|
||||||
// Draw segment labels when being resized or hovered
|
// Draw corner handles for fade control (DaVinci Resolve style)
|
||||||
const dragState = dragStateRef.current;
|
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 shouldShowLabels = (dragState.type === 'resizing' && dragState.segmentId === segment.id) ||
|
const shouldShowLabels = (dragState.type === 'resizing' && dragState.segmentId === segment.id) ||
|
||||||
(hoveredSegmentId === segment.id && dragState.type === 'none');
|
(hoveredSegmentId === segment.id && dragState.type === 'none');
|
||||||
|
|
||||||
@@ -449,6 +561,32 @@ export function WaveformEditorCanvas({
|
|||||||
|
|
||||||
const viewport = viewportRef.current;
|
const viewport = viewportRef.current;
|
||||||
const toleranceMs = viewport.durationMs * (HANDLE_WIDTH_PX / Math.max(canvas?.clientWidth ?? 1, 1));
|
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);
|
const hit = findSegmentHandle(pointerMs, segments, toleranceMs);
|
||||||
|
|
||||||
if (hit) {
|
if (hit) {
|
||||||
@@ -591,6 +729,24 @@ export function WaveformEditorCanvas({
|
|||||||
const nextEnd = clamp(pointerMs, Math.max(minEnd, segment.startMs + MIN_SEGMENT_MS), durationMs);
|
const nextEnd = clamp(pointerMs, Math.max(minEnd, segment.startMs + MIN_SEGMENT_MS), durationMs);
|
||||||
onResizeSegment(segment.id, segment.startMs, nextEnd);
|
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;
|
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.
|
* 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.
|
* Prefers the segment whose midpoint is closest to the pointer when multiple segments overlap.
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ export interface SegmentDraft {
|
|||||||
metadata: SegmentMetadataDraft;
|
metadata: SegmentMetadataDraft;
|
||||||
/** Color used for visual identification in UI and waveform. */
|
/** Color used for visual identification in UI and waveform. */
|
||||||
color: string;
|
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,
|
rating: segment.metadata.rating ?? undefined,
|
||||||
tags: segment.metadata.tags,
|
tags: segment.metadata.tags,
|
||||||
categories: segment.metadata.categories
|
categories: segment.metadata.categories
|
||||||
}
|
},
|
||||||
|
fadeInMs: segment.fadeInMs > 0 ? Math.round(segment.fadeInMs) : undefined,
|
||||||
|
fadeOutMs: segment.fadeOutMs > 0 ? Math.round(segment.fadeOutMs) : undefined
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import type {
|
|||||||
type ChangeListener = () => void;
|
type ChangeListener = () => void;
|
||||||
|
|
||||||
export const CATEGORY_FILTER_UNTAGGED = 'untagged' as const;
|
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 {
|
export interface LibrarySnapshot {
|
||||||
initialized: boolean;
|
initialized: boolean;
|
||||||
@@ -25,6 +27,8 @@ export interface LibrarySnapshot {
|
|||||||
categoryFilter: CategoryFilterValue;
|
categoryFilter: CategoryFilterValue;
|
||||||
lastScan: LibraryScanSummary | null;
|
lastScan: LibraryScanSummary | null;
|
||||||
metadataSuggestionsVersion: number;
|
metadataSuggestionsVersion: number;
|
||||||
|
/** Files created by the most recent split action during this app session. */
|
||||||
|
justSplitFileIds: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,7 +47,8 @@ export class LibraryStore extends EventTarget {
|
|||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
categoryFilter: null,
|
categoryFilter: null,
|
||||||
lastScan: null,
|
lastScan: null,
|
||||||
metadataSuggestionsVersion: 0
|
metadataSuggestionsVersion: 0,
|
||||||
|
justSplitFileIds: []
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,7 +75,8 @@ export class LibraryStore extends EventTarget {
|
|||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
categoryFilter: null,
|
categoryFilter: null,
|
||||||
lastScan: null,
|
lastScan: null,
|
||||||
metadataSuggestionsVersion: nextVersion
|
metadataSuggestionsVersion: nextVersion,
|
||||||
|
justSplitFileIds: []
|
||||||
};
|
};
|
||||||
this.refreshVisibleFiles(this.snapshot.selectedFileId ?? null);
|
this.refreshVisibleFiles(this.snapshot.selectedFileId ?? null);
|
||||||
}
|
}
|
||||||
@@ -263,7 +269,10 @@ export class LibraryStore extends EventTarget {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let visible: AudioFileSummary[];
|
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);
|
visible = files.filter((file) => file.categories.length === 0);
|
||||||
} else if (filter) {
|
} else if (filter) {
|
||||||
visible = files.filter((file) => file.categories.includes(filter));
|
visible = files.filter((file) => file.categories.includes(filter));
|
||||||
@@ -275,7 +284,8 @@ export class LibraryStore extends EventTarget {
|
|||||||
...this.snapshot,
|
...this.snapshot,
|
||||||
visibleFiles: visible,
|
visibleFiles: visible,
|
||||||
selectedFileId,
|
selectedFileId,
|
||||||
focusedFile
|
focusedFile,
|
||||||
|
justSplitFileIds: this.snapshot.justSplitFileIds
|
||||||
};
|
};
|
||||||
this.emitChange();
|
this.emitChange();
|
||||||
}
|
}
|
||||||
@@ -398,21 +408,29 @@ export class LibraryStore extends EventTarget {
|
|||||||
public async splitFile(fileId: number, segments: SplitSegmentRequest[]): Promise<AudioFileSummary[]> {
|
public async splitFile(fileId: number, segments: SplitSegmentRequest[]): Promise<AudioFileSummary[]> {
|
||||||
const created = await window.api.splitFile(fileId, segments);
|
const created = await window.api.splitFile(fileId, segments);
|
||||||
const files = await window.api.listAudioFiles();
|
const files = await window.api.listAudioFiles();
|
||||||
const { categoryFilter } = this.snapshot;
|
const createdIds = created.map((record) => record.id);
|
||||||
let visible: AudioFileSummary[];
|
const fileLookup = new Map(files.map((record) => [record.id, record] as const));
|
||||||
if (categoryFilter === CATEGORY_FILTER_UNTAGGED) {
|
// Prefer fresh copies from the library list so downstream consumers see current metadata.
|
||||||
visible = files.filter((file) => file.categories.length === 0);
|
const justSplitRecords: AudioFileSummary[] = createdIds
|
||||||
} else if (categoryFilter) {
|
.map((id) => fileLookup.get(id) ?? created.find((record) => record.id === id) ?? null)
|
||||||
visible = files.filter((file) => file.categories.includes(categoryFilter));
|
.filter((record): record is AudioFileSummary => record !== null);
|
||||||
} else {
|
|
||||||
visible = files.slice();
|
const primaryId = justSplitRecords.at(0)?.id ?? null;
|
||||||
|
const nextSelection = new Set<number>(justSplitRecords.map((record) => record.id));
|
||||||
|
if (primaryId !== null) {
|
||||||
|
nextSelection.add(primaryId);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.snapshot = {
|
this.snapshot = {
|
||||||
...this.snapshot,
|
...this.snapshot,
|
||||||
files,
|
files,
|
||||||
visibleFiles: visible,
|
visibleFiles: justSplitRecords,
|
||||||
metadataSuggestionsVersion: this.snapshot.metadataSuggestionsVersion + 1
|
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.at(0) ?? null : null
|
||||||
};
|
};
|
||||||
this.emitChange();
|
this.emitChange();
|
||||||
return created;
|
return created;
|
||||||
@@ -462,8 +480,13 @@ export class LibraryStore extends EventTarget {
|
|||||||
|
|
||||||
private refreshVisibleFiles(preferredId?: number | null, emit = true): void {
|
private refreshVisibleFiles(preferredId?: number | null, emit = true): void {
|
||||||
const { files, categoryFilter } = this.snapshot;
|
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[];
|
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);
|
visible = files.filter((file) => file.categories.length === 0);
|
||||||
} else if (categoryFilter) {
|
} else if (categoryFilter) {
|
||||||
visible = files.filter((file) => file.categories.includes(categoryFilter));
|
visible = files.filter((file) => file.categories.includes(categoryFilter));
|
||||||
@@ -500,7 +523,8 @@ export class LibraryStore extends EventTarget {
|
|||||||
visibleFiles: visible,
|
visibleFiles: visible,
|
||||||
selectedFileId: nextSelected,
|
selectedFileId: nextSelected,
|
||||||
selectedFileIds: nextSelectionSet,
|
selectedFileIds: nextSelectionSet,
|
||||||
focusedFile: nextFocused
|
focusedFile: nextFocused,
|
||||||
|
justSplitFileIds: justSplitIds
|
||||||
};
|
};
|
||||||
|
|
||||||
if (emit) {
|
if (emit) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export const IPC_CHANNELS = {
|
|||||||
dialogSelectLibrary: 'dialog:select-library',
|
dialogSelectLibrary: 'dialog:select-library',
|
||||||
libraryScan: 'library:scan',
|
libraryScan: 'library:scan',
|
||||||
libraryList: 'library:list',
|
libraryList: 'library:list',
|
||||||
|
libraryGetById: 'library:get-by-id',
|
||||||
libraryDuplicates: 'library:duplicates',
|
libraryDuplicates: 'library:duplicates',
|
||||||
libraryRename: 'library:rename',
|
libraryRename: 'library:rename',
|
||||||
libraryMove: 'library:move',
|
libraryMove: 'library:move',
|
||||||
@@ -39,6 +40,8 @@ export interface RendererApi {
|
|||||||
rescanLibrary(): Promise<import('./models').LibraryScanSummary>;
|
rescanLibrary(): Promise<import('./models').LibraryScanSummary>;
|
||||||
/** Fetches the current list of audio files. */
|
/** Fetches the current list of audio files. */
|
||||||
listAudioFiles(): Promise<import('./models').AudioFileSummary[]>;
|
listAudioFiles(): Promise<import('./models').AudioFileSummary[]>;
|
||||||
|
/** 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. */
|
/** Fetches groups of duplicate files based on checksum. */
|
||||||
listDuplicates(): Promise<{ checksum: string; files: import('./models').AudioFileSummary[] }[]>;
|
listDuplicates(): Promise<{ checksum: string; files: import('./models').AudioFileSummary[] }[]>;
|
||||||
/** Requests a rename for the target file. */
|
/** Requests a rename for the target file. */
|
||||||
|
|||||||
@@ -138,4 +138,8 @@ export interface SplitSegmentRequest {
|
|||||||
label?: string;
|
label?: string;
|
||||||
/** Metadata overrides to apply to the generated file. */
|
/** Metadata overrides to apply to the generated file. */
|
||||||
metadata?: SegmentMetadataInput;
|
metadata?: SegmentMetadataInput;
|
||||||
|
/** Fade in duration in milliseconds. */
|
||||||
|
fadeInMs?: number;
|
||||||
|
/** Fade out duration in milliseconds. */
|
||||||
|
fadeOutMs?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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
|
|
||||||
*/
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user