mirror of
https://github.com/litruv/AudioSort.git
synced 2026-07-24 10:46:03 +10:00
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:
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user