feat: implement audio file splitting functionality

- Added `splitFile` method to `LibraryStore` for splitting audio files into segments.
- Introduced `SplitSegmentRequest` and `SegmentMetadataInput` interfaces in models for segment metadata handling.
- Created `EditModePanel` component for editing segments, including waveform visualization and metadata assignment.
- Developed `WaveformEditorCanvas` for interactive waveform editing, supporting segment creation and resizing.
- Enhanced metadata handling in IPC with new fields for author and copyright.
- Updated styles for new components and improved UI interactions.
This commit is contained in:
2025-11-11 02:45:34 +11:00
parent bb7d31e748
commit 781187c427
14 changed files with 1286 additions and 118 deletions

View File

@@ -138,9 +138,9 @@ export class MainApp {
ipcMain.removeHandler(IPC_CHANNELS.libraryMove);
ipcMain.removeHandler(IPC_CHANNELS.libraryOrganize);
ipcMain.removeHandler(IPC_CHANNELS.libraryBuffer);
ipcMain.removeHandler(IPC_CHANNELS.libraryMetadata);
ipcMain.removeHandler(IPC_CHANNELS.libraryMetadataSuggestions);
ipcMain.removeHandler(IPC_CHANNELS.libraryUpdateMetadata);
ipcMain.removeHandler(IPC_CHANNELS.libraryMetadata);
ipcMain.removeHandler(IPC_CHANNELS.libraryMetadataSuggestions);
ipcMain.removeHandler(IPC_CHANNELS.libraryUpdateMetadata);
ipcMain.removeHandler(IPC_CHANNELS.libraryWaveformPreview);
ipcMain.removeHandler(IPC_CHANNELS.tagsUpdate);
ipcMain.removeHandler(IPC_CHANNELS.categoriesList);
@@ -163,7 +163,7 @@ export class MainApp {
* Creates the renderer window and loads the UI.
*/
private createWindow(): void {
const preloadPath = this.resolvePreloadPath();
const preloadPath = this.resolvePreloadPath();
this.mainWindow = new BrowserWindow({
width: 1280,
height: 800,
@@ -186,16 +186,18 @@ export class MainApp {
this.mainWindow = null;
});
const devServerUrl = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env?.VITE_DEV_SERVER_URL;
const devServerUrl = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env
?.VITE_DEV_SERVER_URL;
if (devServerUrl) {
this.mainWindow.loadURL(devServerUrl).catch((error: unknown) => {
// eslint-disable-next-line no-console -- Logging is useful during development to diagnose boot issues.
console.error('Failed to load renderer URL', error);
});
} else {
const rendererIndex = this.resolveRendererIndex();
this.mainWindow.loadFile(rendererIndex).catch((error: unknown) => {
console.error('Failed to load renderer bundle', error);
});
this.mainWindow
.loadFile(rendererIndex)
.catch((error: unknown) => console.error('Failed to load renderer bundle', error));
}
}
@@ -212,7 +214,7 @@ export class MainApp {
});
ipcMain.handle(IPC_CHANNELS.dialogSelectLibrary, async () => {
const options: Electron.OpenDialogOptions = { properties: ['openDirectory'] };
const options: Electron.OpenDialogOptions = { properties: ['openDirectory'] };
const targetWindow = this.mainWindow;
const result = targetWindow
? await dialog.showOpenDialog(targetWindow, options)
@@ -239,8 +241,8 @@ export class MainApp {
ipcMain.handle(
IPC_CHANNELS.libraryOrganize,
async (_event: IpcMainInvokeEvent, fileId: number, metadata: { customName?: string | null; author?: string | null; copyright?: string | null; rating?: number }) =>
this.requireLibrary().organizeFile(fileId, metadata)
async (_event: IpcMainInvokeEvent, fileId: number, metadata: { customName?: string; author?: string; copyright?: string; rating?: number }) =>
this.requireLibrary().organizeFile(fileId, metadata)
);
ipcMain.handle(
@@ -283,7 +285,7 @@ export class MainApp {
ipcMain.handle(
IPC_CHANNELS.libraryUpdateMetadata,
async (_event: IpcMainInvokeEvent, fileId: number, metadata: { author?: string | null; copyright?: string | null; rating?: number }) =>
async (_event: IpcMainInvokeEvent, fileId: number, metadata: { author?: string; copyright?: string; rating?: number }) =>
this.requireLibrary().updateFileMetadata(fileId, metadata)
);
}

View File

@@ -4,7 +4,7 @@ import { createHash } from 'node:crypto';
import path from 'node:path';
import fg from 'fast-glob';
import { parse } from 'csv-parse/sync';
import { AppSettings, AudioBufferPayload, AudioFileSummary, CategoryRecord, LibraryScanSummary, TagUpdatePayload } from '../../shared/models';
import { AppSettings, AudioBufferPayload, AudioFileSummary, CategoryRecord, LibraryScanSummary, SplitSegmentRequest, TagUpdatePayload } from '../../shared/models';
import { DatabaseService, FileRecordInput } from './DatabaseService';
import { SettingsService } from './SettingsService';
import { TagService } from './TagService';
@@ -292,7 +292,7 @@ export class LibraryService {
*/
public async getWaveformPreview(fileId: number, pointCount = 160): Promise<{ samples: number[]; rms: number }> {
const record = this.database.getFileById(fileId);
const effectivePoints = Math.min(Math.max(pointCount ?? 160, 32), 512);
const effectivePoints = Math.min(Math.max(pointCount ?? 160, 32), 16384);
const cacheHit = this.waveformPreviewCache.get(fileId);
if (cacheHit && cacheHit.modifiedAt === record.modifiedAt && cacheHit.pointCount === effectivePoints) {
return { samples: cacheHit.samples, rms: cacheHit.rms };
@@ -636,6 +636,199 @@ export class LibraryService {
this.search.rebuildIndex();
}
/**
* Splits an audio file into multiple segments, writing each segment to disk and registering it in the library.
*/
public async splitFile(fileId: number, requestSegments: SplitSegmentRequest[]): Promise<AudioFileSummary[]> {
if (!Array.isArray(requestSegments) || requestSegments.length === 0) {
return [];
}
const record = this.database.getFileById(fileId);
const fileBuffer = await fs.readFile(record.absolutePath);
const wave = new WaveFile(fileBuffer);
const format = (wave as WaveFile & { fmt?: { sampleRate?: number; bitsPerSample?: number } }).fmt;
const rawSamples = wave.getSamples(false, Float64Array) as Float64Array | Float64Array[];
const channels = Array.isArray(rawSamples) ? rawSamples : [rawSamples];
if (channels.length === 0 || channels[0].length === 0) {
throw new Error('Unable to split file: no audio data available.');
}
const sampleRate = format?.sampleRate ?? record.sampleRate ?? null;
if (!sampleRate) {
throw new Error('Unable to split file: missing sample rate information.');
}
const sourceSampleCount = channels[0].length;
const totalDurationMs = Math.round((sourceSampleCount / sampleRate) * 1000);
const bitDepthNumeric = (() => {
const explicit = format?.bitsPerSample;
if (typeof explicit === 'number' && Number.isFinite(explicit) && explicit > 0) {
return explicit;
}
if (typeof record.bitDepth === 'number' && Number.isFinite(record.bitDepth) && record.bitDepth > 0) {
return record.bitDepth;
}
const parsedFromText = Number.parseInt(typeof wave.bitDepth === 'string' ? wave.bitDepth : '', 10);
return Number.isFinite(parsedFromText) && parsedFromText > 0 ? parsedFromText : null;
})();
const bitDepthText = (() => {
if (typeof wave.bitDepth === 'string' && wave.bitDepth.trim().length > 0) {
return wave.bitDepth.trim();
}
if (typeof bitDepthNumeric === 'number') {
return bitDepthNumeric.toString();
}
return '16';
})();
const container = (wave as WaveFile & { container?: string }).container ?? 'RIFF';
let originalMetadata: { author?: string | null; rating?: number; title?: string | null } = {};
try {
const metadata = this.tagService.readMetadata(record.absolutePath);
originalMetadata = {
author: metadata.author ?? null,
rating: metadata.rating ?? undefined,
title: metadata.title ?? null
};
} catch (error) {
console.warn('Failed to read original metadata before splitting', error);
}
const normalizedSegments = requestSegments
.map((segment) => {
const startMs = Number.isFinite(segment.startMs) ? Math.max(0, Math.min(Math.floor(segment.startMs), totalDurationMs)) : 0;
const endMs = Number.isFinite(segment.endMs) ? Math.max(0, Math.min(Math.floor(segment.endMs), totalDurationMs)) : startMs;
const safeEnd = Math.max(startMs + 1, endMs);
return {
startMs,
endMs: safeEnd,
label: segment.label?.trim() ?? undefined,
metadata: segment.metadata
};
})
.filter((segment) => segment.endMs - segment.startMs >= 5)
.sort((a, b) => a.startMs - b.startMs);
if (normalizedSegments.length === 0) {
return [];
}
const libraryRoot = this.settings.ensureLibraryPath();
const sourceDirectory = path.dirname(record.absolutePath);
const relativeFolder = path.dirname(record.relativePath);
const folderForJoin = relativeFolder === '.' ? '' : relativeFolder;
const baseName = path.basename(record.fileName, path.extname(record.fileName));
const usedNames = new Set<string>();
let sequence = 1;
const created: AudioFileSummary[] = [];
for (const segment of normalizedSegments) {
const startSample = Math.max(0, Math.min(Math.floor((segment.startMs / 1000) * sampleRate), sourceSampleCount - 1));
const endSample = Math.max(
startSample + 1,
Math.min(Math.floor((segment.endMs / 1000) * sampleRate), sourceSampleCount)
);
if (endSample <= startSample) {
continue;
}
const segmentChannels = channels.map((channel) => channel.slice(startSample, endSample));
const nextWave = new WaveFile();
nextWave.fromScratch(channels.length, sampleRate, bitDepthText, segmentChannels.length === 1 ? segmentChannels[0] : segmentChannels);
(nextWave as WaveFile & { container?: string }).container = container;
const segmentBytes = Buffer.from(nextWave.toBuffer());
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`;
if (usedNames.has(candidateName)) {
continue;
}
const candidatePath = path.join(sourceDirectory, candidateName);
const exists = await this.pathExists(candidatePath);
if (exists) {
continue;
}
segmentFileName = candidateName;
segmentAbsolutePath = candidatePath;
usedNames.add(candidateName);
break;
}
if (!segmentFileName || !segmentAbsolutePath) {
throw new Error('Failed to allocate filename for split segment.');
}
this.assertWithinLibrary(libraryRoot, segmentAbsolutePath);
await fs.writeFile(segmentAbsolutePath, segmentBytes);
const stats = await fs.stat(segmentAbsolutePath);
const checksum = createHash('md5').update(segmentBytes).digest('hex');
const relativePath = this.toLibraryRelativePath(folderForJoin, segmentFileName);
const durationMs = Math.round(((endSample - startSample) / sampleRate) * 1000);
const resolvedTagsSource = segment.metadata?.tags ?? record.tags;
const resolvedCategoriesSource = segment.metadata?.categories ?? record.categories;
const resolvedTags = Array.isArray(resolvedTagsSource) ? resolvedTagsSource.slice() : record.tags.slice();
const resolvedCategories = Array.isArray(resolvedCategoriesSource)
? resolvedCategoriesSource.slice()
: record.categories.slice();
const fileRecord = this.database.upsertFile({
absolutePath: segmentAbsolutePath,
relativePath,
fileName: segmentFileName,
displayName: path.basename(segmentFileName, '.wav'),
modifiedAt: stats.mtimeMs,
createdAt: Number.isNaN(stats.birthtimeMs) ? null : stats.birthtimeMs,
size: stats.size,
durationMs,
sampleRate,
bitDepth: bitDepthNumeric,
checksum,
tags: resolvedTags,
categories: resolvedCategories
});
const resolvedCustomName = segment.metadata?.customName !== undefined
? this.normaliseMetadataInput(segment.metadata.customName)
: record.customName ?? null;
const updatedRecord = resolvedCustomName !== fileRecord.customName
? this.database.updateCustomName(fileRecord.id, resolvedCustomName ?? null)
: fileRecord;
const resolvedAuthor = segment.metadata?.author !== undefined
? this.normaliseMetadataInput(segment.metadata.author)
: this.normaliseMetadataInput(originalMetadata.author ?? null);
const resolvedRating = segment.metadata?.rating !== undefined
? segment.metadata.rating ?? undefined
: originalMetadata.rating;
this.tagService.writeMetadataOnly(segmentAbsolutePath, {
tags: resolvedTags,
categories: resolvedCategories,
title: resolvedCustomName ?? updatedRecord.displayName,
author: resolvedAuthor ?? undefined,
rating: resolvedRating
});
if (typeof resolvedAuthor === 'string' && resolvedAuthor.length > 0) {
this.updateMetadataSuggestionsCache(resolvedAuthor);
}
this.waveformPreviewCache.delete(updatedRecord.id);
created.push(updatedRecord);
}
this.waveformPreviewCache.delete(record.id);
this.resetMetadataSuggestionsCache();
this.search.rebuildIndex();
return created;
}
/**
* Returns the previously parsed UCS category catalog.
*/

View File

@@ -11,14 +11,22 @@ export class TagService {
/**
* Applies tags and categories to a file record and embeds the 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);
const normalisedCategories = this.normaliseValues(categories);
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
categories: normalisedCategories,
title: existing.title,
author: existing.author,
rating: existing.rating
});
return updated;
}
@@ -69,15 +77,16 @@ export class TagService {
/**
* Writes tags and categories to an organized file as embedded metadata.
* All metadata fields are required to prevent accidental clearing.
*/
public writeMetadataOnly(
filePath: string,
metadata: {
tags: string[];
categories: string[];
title?: string | null;
author?: string | null;
rating?: number;
title: string | null | undefined;
author: string | null | undefined;
rating: number | undefined;
}
): void {
this.writeWaveMetadata(filePath, metadata);
@@ -85,15 +94,16 @@ export class TagService {
/**
* Writes a simple INFO chunk with the provided metadata. Failures are swallowed so DB state remains authoritative.
* All metadata fields must be explicitly provided to prevent accidental data loss.
*/
private writeWaveMetadata(
filePath: string,
metadata: {
tags: string[];
categories: string[];
title?: string | null;
author?: string | null;
rating?: number;
title: string | null | undefined;
author: string | null | undefined;
rating: number | undefined;
}
): void {
try {