feat: enhance waveform editor with playback controls and segment interaction

- Added onPlayFromCursor and playbackCursorMs props to WaveformEditorCanvas for playback control.
- Implemented visual feedback for segment selection and hover states, including color adjustments and labels.
- Introduced smooth cursor animation for better user experience during interaction.
- Enhanced segment metadata handling in types and IPC for better integration with the library.
- Added focusOnFile method in LibraryStore to manage file selection and refresh.
- Updated styles for edit mode, including new classes for waveform and playback controls.
- Introduced new IPC channel for splitting audio files and updated related interfaces.
- Added parentFileId to AudioFileSummary for tracking source files.
- Implemented tests for tagging service to ensure metadata integrity and tag preservation.
- Created scrubWorklet and assets type definitions for future audio processing features.
This commit is contained in:
2025-11-11 08:19:19 +11:00
parent 781187c427
commit 6515a30a0e
20 changed files with 1871 additions and 288 deletions

View File

@@ -3,7 +3,7 @@ import path from 'node:path';
import { app, BrowserWindow, dialog, ipcMain, nativeTheme, Menu } from 'electron';
import type { IpcMainInvokeEvent } from 'electron';
import { IPC_CHANNELS } from '../shared/ipc';
import { TagUpdatePayload } from '../shared/models';
import { SplitSegmentRequest, TagUpdatePayload } from '../shared/models';
import { DatabaseService } from './services/DatabaseService';
import { LibraryService } from './services/LibraryService';
import { SearchService } from './services/SearchService';
@@ -141,6 +141,7 @@ export class MainApp {
ipcMain.removeHandler(IPC_CHANNELS.libraryMetadata);
ipcMain.removeHandler(IPC_CHANNELS.libraryMetadataSuggestions);
ipcMain.removeHandler(IPC_CHANNELS.libraryUpdateMetadata);
ipcMain.removeHandler(IPC_CHANNELS.librarySplit);
ipcMain.removeHandler(IPC_CHANNELS.libraryWaveformPreview);
ipcMain.removeHandler(IPC_CHANNELS.tagsUpdate);
ipcMain.removeHandler(IPC_CHANNELS.categoriesList);
@@ -241,7 +242,11 @@ export class MainApp {
ipcMain.handle(
IPC_CHANNELS.libraryOrganize,
async (_event: IpcMainInvokeEvent, fileId: number, metadata: { customName?: string; author?: string; copyright?: string; rating?: number }) =>
async (
_event: IpcMainInvokeEvent,
fileId: number,
metadata: { customName?: string | null; author?: string | null; copyright?: string | null; rating?: number }
) =>
this.requireLibrary().organizeFile(fileId, metadata)
);
@@ -259,6 +264,12 @@ export class MainApp {
this.requireLibrary().deleteFiles(fileIds)
);
ipcMain.handle(
IPC_CHANNELS.librarySplit,
async (_event: IpcMainInvokeEvent, fileId: number, segments: SplitSegmentRequest[]) =>
this.requireLibrary().splitFile(fileId, segments)
);
ipcMain.handle(IPC_CHANNELS.libraryBuffer, async (_event: IpcMainInvokeEvent, fileId: number) =>
this.requireLibrary().getAudioBuffer(fileId)
);
@@ -285,7 +296,11 @@ export class MainApp {
ipcMain.handle(
IPC_CHANNELS.libraryUpdateMetadata,
async (_event: IpcMainInvokeEvent, fileId: number, metadata: { author?: string; copyright?: string; rating?: number }) =>
async (
_event: IpcMainInvokeEvent,
fileId: number,
metadata: { author?: string | null; copyright?: string | null; rating?: number }
) =>
this.requireLibrary().updateFileMetadata(fileId, metadata)
);
}

View File

@@ -33,6 +33,8 @@ export interface FileRecordInput {
tags?: string[];
/** Optional category payload (stored as JSON string). */
categories?: string[];
/** Optional parent file reference when generated from another file. */
parentFileId?: number | null;
}
export interface FileRecordRow extends AudioFileSummary {}
@@ -89,7 +91,8 @@ export class DatabaseService {
bit_depth,
checksum,
tags_json,
categories_json
categories_json,
parent_file_id
) VALUES (
@absolutePath,
@relativePath,
@@ -103,7 +106,8 @@ export class DatabaseService {
@bitDepth,
@checksum,
@tagsJson,
@categoriesJson
@categoriesJson,
@parentFileId
)
ON CONFLICT(absolute_path) DO UPDATE SET
library_relative_path = excluded.library_relative_path,
@@ -117,7 +121,8 @@ export class DatabaseService {
bit_depth = excluded.bit_depth,
checksum = excluded.checksum,
tags_json = CASE WHEN files.tags_json = '[]' THEN excluded.tags_json ELSE files.tags_json END,
categories_json = CASE WHEN files.categories_json = '[]' THEN excluded.categories_json ELSE files.categories_json END
categories_json = CASE WHEN files.categories_json = '[]' THEN excluded.categories_json ELSE files.categories_json END,
parent_file_id = CASE WHEN excluded.parent_file_id IS NOT NULL THEN excluded.parent_file_id ELSE files.parent_file_id END
RETURNING *`
);
@@ -132,9 +137,10 @@ export class DatabaseService {
durationMs: record.durationMs,
sampleRate: record.sampleRate,
bitDepth: record.bitDepth,
checksum: record.checksum,
checksum: record.checksum,
tagsJson: JSON.stringify(record.tags ?? []),
categoriesJson: JSON.stringify(record.categories ?? [])
categoriesJson: JSON.stringify(record.categories ?? []),
parentFileId: record.parentFileId ?? null
}) as DbRow | undefined;
if (!row) {
@@ -145,22 +151,35 @@ export class DatabaseService {
}
/**
* Updates the stored tags and categories for a file.
* Updates the stored tags and/or categories for a file.
* When a field is omitted it remains unchanged.
*/
public updateTagging(fileId: number, tags: string[], categories: string[]): AudioFileSummary {
public updateTagging(fileId: number, tags?: string[], categories?: string[]): AudioFileSummary {
const connection = this.requireDb();
const updates: string[] = [];
const parameters: Record<string, unknown> = { id: fileId };
if (tags !== undefined) {
updates.push('tags_json = @tags');
parameters.tags = JSON.stringify(tags);
}
if (categories !== undefined) {
updates.push('categories_json = @categories');
parameters.categories = JSON.stringify(categories);
}
if (updates.length === 0) {
return this.getFileById(fileId);
}
const statement = connection.prepare(
`UPDATE files
SET tags_json = @tags,
categories_json = @categories
SET ${updates.join(', ')}
WHERE id = @id
RETURNING *`
);
const row = statement.get({
id: fileId,
tags: JSON.stringify(tags),
categories: JSON.stringify(categories)
}) as DbRow | undefined;
const row = statement.get(parameters) as DbRow | undefined;
if (!row) {
throw new Error(`File with id ${fileId} not found`);
}
@@ -408,6 +427,7 @@ export class DatabaseService {
sampleRate: row.sample_rate === null ? null : (row.sample_rate as number),
bitDepth: row.bit_depth === null ? null : (row.bit_depth as number),
checksum: typeof row.checksum === 'string' ? (row.checksum as string) : null,
parentFileId: typeof row.parent_file_id === 'number' ? (row.parent_file_id as number) : null,
tags: this.parseJsonArray(row.tags_json),
categories: this.parseJsonArray(row.categories_json),
customName: typeof row.custom_name === 'string' ? (row.custom_name as string) : null
@@ -444,7 +464,8 @@ export class DatabaseService {
bit_depth INTEGER,
checksum TEXT,
tags_json TEXT NOT NULL DEFAULT '[]',
categories_json TEXT NOT NULL DEFAULT '[]'
categories_json TEXT NOT NULL DEFAULT '[]',
parent_file_id INTEGER REFERENCES files(id)
);
CREATE INDEX IF NOT EXISTS idx_files_display_name ON files(display_name);
CREATE INDEX IF NOT EXISTS idx_files_modified ON files(modified_at);
@@ -478,6 +499,7 @@ export class DatabaseService {
this.addColumnIfMissing(connection, 'files', 'created_at', 'INTEGER');
this.addColumnIfMissing(connection, 'files', 'checksum', 'TEXT');
this.addColumnIfMissing(connection, 'files', 'custom_name', 'TEXT');
this.addColumnIfMissing(connection, 'files', 'parent_file_id', 'INTEGER');
// Create checksum index after ensuring column exists
connection.exec('CREATE INDEX IF NOT EXISTS idx_files_checksum ON files(checksum)');

View File

@@ -31,7 +31,6 @@ export class LibraryService {
private readonly organization: OrganizationService;
private metadataSuggestionCache: { authors: Set<string> } | null = null;
private readonly waveformPreviewCache = new Map<number, { modifiedAt: number; pointCount: number; samples: number[]; rms: number }>();
public constructor(
private readonly database: DatabaseService,
private readonly settings: SettingsService,
@@ -49,10 +48,10 @@ export class LibraryService {
return;
}
const fileContent = await fs.readFile(csvAbsolutePath, 'utf-8');
const rows = parse(fileContent, {
columns: true,
skip_empty_lines: true
}) as CsvCategoryRow[];
const rows = parse(fileContent, {
columns: true,
skip_empty_lines: true
}) as CsvCategoryRow[];
for (const row of rows) {
const category: CategoryRecord = {
id: row.CatID,
@@ -87,8 +86,9 @@ export class LibraryService {
const cleanedTempFiles = await this.cleanupTempFiles();
const libraryRoot = this.settings.ensureLibraryPath();
const existing = this.database.listFiles();
const existingByPath = new Map(existing.map((file) => [file.absolutePath, file] as const));
const existingByChecksum = new Map(existing.filter((file) => file.checksum).map((file) => [file.checksum!, file] as const));
const existingByPath = new Map(existing.map((file) => [file.absolutePath, file] as const));
const existingByChecksum = new Map(existing.filter((file) => file.checksum).map((file) => [file.checksum!, file] as const));
const discoveredByPath = new Map<string, AudioFileSummary>();
const pattern = ['**/*.wav', '**/*.wave'];
const absolutePaths = await fg(pattern, {
@@ -117,9 +117,29 @@ export class LibraryService {
const knownFile = knownByPath ?? knownByChecksum ?? null;
const wasKnown = knownFile !== null;
// Read embedded WAV metadata (author, copyright, rating, title)
// Read embedded WAV metadata (author, copyright, rating, title, parentId)
const embeddedMetadata = this.tagService.readMetadata(absolutePath);
let parentFileId = knownFile?.parentFileId ?? null;
// First try embedded metadata parentId
if (parentFileId === null && embeddedMetadata.parentId !== undefined) {
parentFileId = embeddedMetadata.parentId;
}
// Fall back to filename pattern matching for segments
if (parentFileId === null) {
const segmentMatch = fileName.match(/^(.*)_segment\d+(\.[^.]+)$/i);
if (segmentMatch) {
const parentFileName = `${segmentMatch[1]}${segmentMatch[2]}`;
const parentAbsolutePath = path.join(path.dirname(absolutePath), parentFileName);
const parentRecord = existingByPath.get(parentAbsolutePath) ?? discoveredByPath.get(parentAbsolutePath) ?? null;
if (parentRecord) {
parentFileId = parentRecord.id;
}
}
}
const record: FileRecordInput = {
absolutePath,
relativePath,
@@ -133,14 +153,36 @@ export class LibraryService {
bitDepth: metadata.bitDepth,
checksum,
tags: metadata.tags.length > 0 ? metadata.tags : (knownFile?.tags ?? []),
categories: metadata.categories.length > 0 ? metadata.categories : (knownFile?.categories ?? [])
categories: metadata.categories.length > 0 ? metadata.categories : (knownFile?.categories ?? []),
parentFileId
};
const upserted = this.database.upsertFile(record);
// Update custom name from embedded title if present, otherwise keep existing
const customName = embeddedMetadata.title?.trim() || knownFile?.customName || null;
if (customName !== upserted.customName) {
this.database.updateCustomName(upserted.id, customName);
const finalRecord = customName !== upserted.customName
? this.database.updateCustomName(upserted.id, customName)
: upserted;
existingByPath.set(absolutePath, finalRecord);
if (checksum) {
existingByChecksum.set(checksum, finalRecord);
}
discoveredByPath.set(absolutePath, finalRecord);
if (parentFileId !== null) {
const embeddedParent = embeddedMetadata.parentId ?? null;
if (embeddedParent === null || embeddedParent !== parentFileId) {
this.tagService.writeMetadataOnly(absolutePath, {
tags: finalRecord.tags,
categories: finalRecord.categories,
title: customName ?? embeddedMetadata.title ?? finalRecord.displayName,
author: embeddedMetadata.author ?? null,
rating: embeddedMetadata.rating,
copyright: embeddedMetadata.copyright ?? null,
parentId: parentFileId
});
}
}
if (wasKnown) {
@@ -510,7 +552,9 @@ export class LibraryService {
categories: updatedRecord.categories,
title: effectiveCustomName,
author: mergedAuthor,
rating: mergedRating
rating: mergedRating,
copyright: existing.copyright ?? null,
parentId: updatedRecord.parentFileId ?? null
});
// Update suggestions cache for any metadata that was provided
@@ -604,7 +648,9 @@ export class LibraryService {
categories: updated.categories,
title: effectiveCustomName,
author: mergedAuthor,
rating: mergedRating
rating: mergedRating,
copyright: existing.copyright ?? null,
parentId: updated.parentFileId ?? null
});
// Update suggestions cache for any metadata that was provided
@@ -683,13 +729,14 @@ export class LibraryService {
})();
const container = (wave as WaveFile & { container?: string }).container ?? 'RIFF';
let originalMetadata: { author?: string | null; rating?: number; title?: string | null } = {};
let originalMetadata: { author?: string | null; rating?: number; title?: string | null; copyright?: string | null } = {};
try {
const metadata = this.tagService.readMetadata(record.absolutePath);
originalMetadata = {
author: metadata.author ?? null,
rating: metadata.rating ?? undefined,
title: metadata.title ?? null
title: metadata.title ?? null,
copyright: metadata.copyright ?? null
};
} catch (error) {
console.warn('Failed to read original metadata before splitting', error);
@@ -739,12 +786,33 @@ export class LibraryService {
(nextWave as WaveFile & { container?: string }).container = container;
const segmentBytes = Buffer.from(nextWave.toBuffer());
// Determine the segment name part - use label if available, otherwise use sequence
let segmentNamePart = '';
if (segment.label) {
const sanitized = this.organization.sanitizeCustomName(segment.label);
if (sanitized) {
segmentNamePart = sanitized;
}
}
let segmentFileName = '';
let segmentAbsolutePath = '';
for (let attempt = 0; attempt < 1000; attempt += 1) {
const suffix = this.organization.formatSequenceNumber(sequence);
sequence += 1;
const candidateName = `${baseName}_segment${suffix}.wav`;
let candidateName: string;
if (segmentNamePart) {
// Use label-based name, with optional suffix for duplicates
if (attempt === 0) {
candidateName = `${baseName}_${segmentNamePart}.wav`;
} else {
candidateName = `${baseName}_${segmentNamePart}_${this.organization.formatSequenceNumber(attempt)}.wav`;
}
} else {
// Fallback to sequential numbering
const suffix = this.organization.formatSequenceNumber(sequence);
sequence += 1;
candidateName = `${baseName}_segment${suffix}.wav`;
}
if (usedNames.has(candidateName)) {
continue;
}
@@ -789,12 +857,15 @@ export class LibraryService {
bitDepth: bitDepthNumeric,
checksum,
tags: resolvedTags,
categories: resolvedCategories
categories: resolvedCategories,
parentFileId: record.id
});
const resolvedCustomName = segment.metadata?.customName !== undefined
? this.normaliseMetadataInput(segment.metadata.customName)
: record.customName ?? null;
: segment.label !== undefined
? this.normaliseMetadataInput(segment.label)
: record.customName ?? null;
const updatedRecord = resolvedCustomName !== fileRecord.customName
? this.database.updateCustomName(fileRecord.id, resolvedCustomName ?? null)
: fileRecord;
@@ -811,7 +882,9 @@ export class LibraryService {
categories: resolvedCategories,
title: resolvedCustomName ?? updatedRecord.displayName,
author: resolvedAuthor ?? undefined,
rating: resolvedRating
rating: resolvedRating,
copyright: originalMetadata.copyright ?? null,
parentId: record.id
});
if (typeof resolvedAuthor === 'string' && resolvedAuthor.length > 0) {
@@ -872,7 +945,9 @@ export class LibraryService {
categories: record.categories,
title: record.customName ?? existing.title,
author: requestedAuthor !== undefined ? requestedAuthor : existing.author ?? null,
rating: metadata.rating !== undefined ? metadata.rating : existing.rating
rating: metadata.rating !== undefined ? metadata.rating : existing.rating,
copyright: existing.copyright ?? null,
parentId: record.parentFileId ?? null
};
// Write merged metadata to the WAV file

View File

@@ -10,23 +10,25 @@ export class TagService {
public constructor(private readonly database: DatabaseService) {}
/**
* Applies tags and categories to a file record and embeds the metadata into the WAV container.
* Applies category updates (and optional tag overrides) then embeds metadata into the WAV container.
* Preserves existing author, title, and rating fields.
*/
public applyTagging(fileId: number, tags: string[], categories: string[]): AudioFileSummary {
const normalisedTags = this.normaliseValues(tags);
public applyTagging(fileId: number, tags: string[] | undefined, categories: string[]): AudioFileSummary {
const normalisedCategories = this.normaliseValues(categories);
const normalisedTags = Array.isArray(tags) ? this.normaliseValues(tags) : undefined;
const updated = this.database.updateTagging(fileId, normalisedTags, normalisedCategories);
// Read existing metadata to preserve author, title, and rating
const existing = this.readMetadata(updated.absolutePath);
this.writeWaveMetadata(updated.absolutePath, {
tags: normalisedTags,
categories: normalisedCategories,
tags: normalisedTags ?? updated.tags,
categories: normalisedCategories,
title: existing.title,
author: existing.author,
rating: existing.rating
rating: existing.rating,
copyright: existing.copyright,
parentId: existing.parentId ?? updated.parentFileId ?? null
});
return updated;
}
@@ -52,6 +54,8 @@ export class TagService {
author?: string;
title?: string;
rating?: number;
copyright?: string;
parentId?: number;
} {
try {
const buffer = fs.readFileSync(filePath);
@@ -63,10 +67,44 @@ export class TagService {
rating = Math.floor(ratingValue / 2);
}
const copyright = tags.ICOP?.trim() || undefined;
// Try to read parentId from JSON comment first
let parentId: number | undefined;
const comment = tags.ICMT?.trim();
if (comment) {
try {
const parsed = JSON.parse(comment);
if (parsed && typeof parsed.parentId === 'number') {
parentId = parsed.parentId;
}
} catch {
// Not JSON or invalid, try fallback to IPAR
const parentRaw = tags.IPAR?.trim();
if (parentRaw && parentRaw.length > 0) {
const parsedNum = Number.parseInt(parentRaw, 10);
if (Number.isFinite(parsedNum)) {
parentId = parsedNum;
}
}
}
} else {
// No comment, try fallback to IPAR
const parentRaw = tags.IPAR?.trim();
if (parentRaw && parentRaw.length > 0) {
const parsedNum = Number.parseInt(parentRaw, 10);
if (Number.isFinite(parsedNum)) {
parentId = parsedNum;
}
}
}
return {
author: tags.IART?.trim() || undefined,
title: tags.INAM?.trim() || undefined,
rating
rating,
copyright,
parentId
};
} catch (error) {
// eslint-disable-next-line no-console -- Logging to devtools console is helpful for diagnosis.
@@ -84,9 +122,11 @@ export class TagService {
metadata: {
tags: string[];
categories: string[];
title: string | null | undefined;
author: string | null | undefined;
rating: number | undefined;
title?: string | null;
author?: string | null;
rating?: number;
copyright?: string | null;
parentId?: number | null;
}
): void {
this.writeWaveMetadata(filePath, metadata);
@@ -101,9 +141,11 @@ export class TagService {
metadata: {
tags: string[];
categories: string[];
title: string | null | undefined;
author: string | null | undefined;
rating: number | undefined;
title?: string | null;
author?: string | null;
rating?: number;
copyright?: string | null;
parentId?: number | null;
}
): void {
try {
@@ -114,22 +156,47 @@ export class TagService {
listInfoTags?: Record<string, string>;
};
const tagValuesList = metadata.tags.map((entry) => entry.trim()).filter((entry) => entry.length > 0);
const categoryValuesList = metadata.categories.map((entry) => entry.trim()).filter((entry) => entry.length > 0);
const tagValuesList = (metadata.tags ?? [])
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
const categoryValuesList = (metadata.categories ?? [])
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
const tagText = tagValuesList.length > 0 ? tagValuesList.join('; ') : null;
const categoryText = categoryValuesList.length > 0 ? categoryValuesList.join('; ') : null;
const primaryCategory = categoryValuesList.at(0) ?? null;
const trimmedTitle = metadata.title?.toString().trim();
const effectiveTitle = trimmedTitle && trimmedTitle.length > 0 ? trimmedTitle : null;
const trimmedAuthor = metadata.author?.toString().trim();
const effectiveAuthor = trimmedAuthor && trimmedAuthor.length > 0 ? trimmedAuthor : null;
const trimmedCopyright = metadata.copyright?.toString().trim();
const effectiveCopyright = trimmedCopyright && trimmedCopyright.length > 0 ? trimmedCopyright : null;
const parentText = metadata.parentId !== undefined && metadata.parentId !== null
? String(metadata.parentId)
: null;
const commentPayload = {
version: 1,
tags: tagValuesList,
categories: categoryValuesList,
parentId: metadata.parentId ?? null,
title: effectiveTitle,
author: effectiveAuthor,
rating: metadata.rating ?? null
} satisfies Record<string, unknown>;
const commentText = JSON.stringify(commentPayload);
const tagValues: Record<string, string | null> = {
IKEY: tagText,
ICMT: tagText,
ICMT: commentText,
ISBJ: categoryText,
ISUB: primaryCategory,
IGNR: null,
INAM: metadata.title?.trim()?.length ? metadata.title.trim() : null,
IART: metadata.author?.trim()?.length ? metadata.author.trim() : null,
INAM: effectiveTitle,
IART: effectiveAuthor,
IRTD: metadata.rating && metadata.rating > 0 ? String(metadata.rating * 2) : null,
ICOP: effectiveCopyright,
IPAR: parentText,
ISFT: 'AudioSort'
};
@@ -139,11 +206,13 @@ export class TagService {
fs.writeFileSync(filePath, updatedBuffer);
console.log(`Wrote metadata to ${filePath}:`, {
tags: metadata.tags.join(', '),
tags: Array.isArray(metadata.tags) ? metadata.tags.join(', ') : '',
categories: metadata.categories.join(', '),
title: metadata.title,
author: metadata.author,
rating: metadata.rating
title: effectiveTitle,
author: effectiveAuthor,
copyright: effectiveCopyright,
rating: metadata.rating,
parentId: metadata.parentId ?? null
});
} catch (error) {
// eslint-disable-next-line no-console -- Logging to devtools console is helpful for diagnosis.