Skip to content

Commit 2e7dc0f

Browse files
committed
refactor: deepen IPC, export, chop-editor, and analysis modules
Five behavior-preserving deepening refactors: - IPC contract: single source of truth in electron/ipc-contract.ts. The preload bridge and the Window.api type derive from it, and main handlers register through a typed handle(), so renderer/main drift becomes a compile error instead of a runtime "no handler" / dropped-field bug. - Export pipeline: extract exportClips() so audio:exportRegions and packs:export share one renderer of "chop -> hardware-ready file" instead of duplicating the ffmpeg loop. - Chop editor: lift undo/redo history and autosave out of AudioWaveform into useChopHistory and useChopAutosave (809 -> 720 lines). - Library store: dedupe the fire-and-forget background analysis in importFolder and saveChops into one backfillAnalysis helper. - Audio analysis: move the worker pool behind its runOnWorker interface into audioAnalysis.pool.ts, leaving the facade readable. Claude-Session: https://claude.ai/code/session_01JNA5nzPkMqVmAcSEqkdAMh
1 parent be879b4 commit 2e7dc0f

18 files changed

Lines changed: 548 additions & 449 deletions

electron/ipc-contract.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// Single source of truth for the renderer<->main bridge. The grouped `Api` shape is what the
2+
// renderer sees as `window.api`; the preload bridge is typed against it (so it can't forward the
3+
// wrong args), `Window.api` is declared from it, and main handlers are registered through the
4+
// typed `handle` helper keyed by the flattened channel map below. Change a signature here and all
5+
// three faces fail to compile until they agree.
6+
import type {
7+
Sample,
8+
Pack,
9+
PackSlot,
10+
Project,
11+
ProjectRegion,
12+
ProjectChop,
13+
PackSourceItem,
14+
ExportRegionsParams,
15+
FreesoundPage,
16+
} from './types'
17+
18+
export type Api = {
19+
library: {
20+
getSamples: (filters?: { bpm?: number; key?: string; tags?: string[]; projectId?: string }) => Promise<Sample[]>
21+
addSample: (data: { name: string; filePath: string; duration?: number }) => Promise<Sample>
22+
updateSample: (id: string, data: Partial<Pick<Sample, 'name' | 'bpm' | 'musicalKey' | 'tags' | 'waveformData'>>) => Promise<void>
23+
deleteSample: (id: string) => Promise<void>
24+
saveChops: (params: {
25+
sourceFilePath: string
26+
regions: Array<{ start: number; end: number; name: string }>
27+
projectId?: string
28+
}) => Promise<Sample[]>
29+
importFolder: (folderPath: string) => Promise<{ imported: number; skipped: number }>
30+
getPackSlotRefCount: (id: string) => Promise<number>
31+
getOrphans: () => Promise<Sample[]>
32+
deleteOrphans: (ids: string[]) => Promise<{ deleted: number }>
33+
}
34+
projects: {
35+
getAll: () => Promise<Project[]>
36+
get: (id: string) => Promise<Project | null>
37+
save: (data: { name: string; sourcePath: string | null; sourceName?: string | null; source?: 'local' | 'freesound'; regions: ProjectRegion[] }) => Promise<Project>
38+
update: (id: string, data: Partial<Pick<Project, 'name' | 'sourcePath' | 'regions'>>) => Promise<void>
39+
getChops: (projectId: string) => Promise<ProjectChop[]>
40+
getAllChops: () => Promise<Array<ProjectChop & { projectName: string; sourcePath: string | null; source: 'local' | 'freesound' }>>
41+
upsertChops: (projectId: string, regions: ProjectRegion[]) => Promise<ProjectChop[]>
42+
delete: (id: string) => Promise<void>
43+
duplicate: (id: string) => Promise<Project | null>
44+
}
45+
audio: {
46+
exportRegions: (params: ExportRegionsParams) => Promise<{ filesWritten: number }>
47+
trimSource: (params: { sourceFilePath: string; start: number; end: number }) => Promise<{ filePath: string; duration: number }>
48+
}
49+
fs: {
50+
getPathForFile: (file: File) => string
51+
pickFile: () => Promise<string | null>
52+
pickFolder: () => Promise<string | null>
53+
}
54+
settings: {
55+
get: (key: string) => Promise<unknown>
56+
set: (key: string, value: unknown) => Promise<void>
57+
}
58+
freesound: {
59+
search: (query: string, page?: number, sort?: string, filter?: string) => Promise<FreesoundPage>
60+
download: (soundId: number, name: string, previewUrl: string) => Promise<{ name: string; filePath: string }>
61+
}
62+
shell: {
63+
openExternal: (url: string) => Promise<void>
64+
}
65+
packs: {
66+
getAll: () => Promise<Pack[]>
67+
getSlots: (packId: string) => Promise<PackSlot[]>
68+
getProfiles: () => Promise<Array<{ id: string; name: string; padCount: number }>>
69+
create: (data: Pick<Pack, 'name' | 'hardwareProfile'>) => Promise<Pack>
70+
upsertSlot: (packId: string, slotNumber: number, source: PackSourceItem) => Promise<void>
71+
removeSlot: (packId: string, slotNumber: number) => Promise<void>
72+
rename: (id: string, name: string) => Promise<void>
73+
delete: (id: string) => Promise<void>
74+
export: (packId: string, outputDir: string) => Promise<{ filesWritten: number }>
75+
}
76+
}
77+
78+
type UnionToIntersection<U> = (U extends unknown ? (k: U) => void : never) extends (k: infer I) => void ? I : never
79+
80+
// Flatten the grouped Api into the flat `group:method` channel names used over IPC, preserving
81+
// each method's exact signature. This is what main-side `handle` is keyed by.
82+
export type ApiChannels = UnionToIntersection<
83+
{
84+
[G in keyof Api]: {
85+
[M in keyof Api[G] as `${G & string}:${M & string}`]: Api[G][M]
86+
}
87+
}[keyof Api]
88+
>

electron/main/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { app, BrowserWindow, dialog, shell, session, protocol, net, ipcMain } from 'electron'
1+
import { app, BrowserWindow, dialog, shell, session, protocol, net } from 'electron'
22
import { release } from 'node:os'
33
import { dirname, join } from 'node:path'
44
import { appendFileSync, existsSync, mkdirSync } from 'node:fs'
55
import { fileURLToPath, pathToFileURL } from 'node:url'
66
import { update } from './update'
77
import { initDatabase } from './db/index'
88
import { materializeProjectChops } from './services/materializeChops'
9+
import { handle } from './ipc/handle'
910
import { registerLibraryHandlers } from './ipc/library'
1011
import { registerAudioHandlers } from './ipc/audio'
1112
import { registerFilesystemHandlers } from './ipc/filesystem'
@@ -229,7 +230,7 @@ app.whenReady().then(async () => {
229230
})
230231
}
231232

232-
ipcMain.handle('shell:openExternal', (_, url: string) => {
233+
handle('shell:openExternal', (_, url: string) => {
233234
if (url.startsWith('https:')) shell.openExternal(url)
234235
})
235236

electron/main/ipc/audio.ts

Lines changed: 14 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,29 @@
1-
import { ipcMain } from 'electron'
2-
import path from 'node:path'
3-
import fs from 'node:fs'
4-
import { getProfile, applyProfileFormat } from '../hardware/profiles'
1+
import { handle } from './handle'
2+
import { getProfile } from '../hardware/profiles'
3+
import { exportClips, type ExportClip } from '../services/export'
54
import { trimSourceToCache } from '../services/trim'
6-
import { configureFfmpeg, ffmpeg } from '../services/ffmpeg'
5+
import { configureFfmpeg } from '../services/ffmpeg'
76
import type { ExportRegionsParams, TrimSourceParams } from '../../types'
87

98
configureFfmpeg()
109

1110
export function registerAudioHandlers(): void {
12-
ipcMain.handle('audio:exportRegions', async (_, params: ExportRegionsParams) => {
11+
handle('audio:exportRegions', async (_, params: ExportRegionsParams) => {
1312
const { regions, sourceFilePath, outputDir, profileId } = params
1413
const profile = getProfile(profileId)
1514

16-
if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true })
15+
const clips: ExportClip[] = regions.map((region, index) => ({
16+
sourcePath: sourceFilePath,
17+
slotNumber: index,
18+
name: region.name || `sample_${index + 1}`,
19+
start: region.start,
20+
end: region.end,
21+
}))
1722

18-
await Promise.all(
19-
regions.map((region, index) =>
20-
new Promise<void>((resolve, reject) => {
21-
const outputFile = path.join(outputDir, profile.fileName(index, region.name || `sample_${index + 1}`))
22-
23-
applyProfileFormat(profile, ffmpeg(sourceFilePath)
24-
.setStartTime(region.start)
25-
.setDuration(region.end - region.start))
26-
.output(outputFile)
27-
.on('end', () => resolve())
28-
.on('error', reject)
29-
.run()
30-
})
31-
)
32-
)
33-
34-
return { filesWritten: regions.length }
23+
return exportClips(profile, clips, outputDir)
3524
})
3625

37-
ipcMain.handle('audio:trimSource', async (_, params: TrimSourceParams) => {
26+
handle('audio:trimSource', async (_, params: TrimSourceParams) => {
3827
const { sourceFilePath, start, end } = params
3928
return trimSourceToCache(sourceFilePath, start, end)
4029
})

electron/main/ipc/filesystem.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
1-
import { ipcMain, dialog } from 'electron'
1+
import { dialog } from 'electron'
2+
import { handle } from './handle'
23

34
export function registerFilesystemHandlers(): void {
4-
ipcMain.handle('fs:pickFile', async () => {
5+
handle('fs:pickFile', async () => {
56
const result = await dialog.showOpenDialog({
67
properties: ['openFile'],
78
filters: [{ name: 'Audio', extensions: ['wav', 'mp3', 'flac', 'aiff', 'ogg', 'm4a'] }],
89
})
910
return result.canceled ? null : result.filePaths[0]
1011
})
1112

12-
ipcMain.handle('fs:pickFolder', async () => {
13+
handle('fs:pickFolder', async () => {
1314
const result = await dialog.showOpenDialog({
1415
properties: ['openDirectory', 'createDirectory'],
1516
})

electron/main/ipc/freesound.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { ipcMain, app, net } from 'electron'
1+
import { app, net } from 'electron'
22
import path from 'node:path'
33
import fs from 'node:fs'
4+
import { handle } from './handle'
45

56
const BASE = 'https://freesound.org/apiv2'
67

@@ -14,7 +15,7 @@ function getApiKey(): string {
1415
}
1516

1617
export function registerFreesoundHandlers(): void {
17-
ipcMain.handle('freesound:search', async (_, query: string, page = 1, sort = 'score', filter = '') => {
18+
handle('freesound:search', async (_, query: string, page = 1, sort = 'score', filter = '') => {
1819
const token = getApiKey()
1920
if (!token) throw new Error('No Freesound API key configured')
2021
const params: Record<string, string> = {
@@ -32,7 +33,7 @@ export function registerFreesoundHandlers(): void {
3233
return res.json()
3334
})
3435

35-
ipcMain.handle('freesound:download', async (_, _soundId: number, name: string, previewUrl: string) => {
36+
handle('freesound:download', async (_, _soundId: number, name: string, previewUrl: string) => {
3637
const dir = path.join(app.getPath('userData'), 'staging')
3738
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
3839
const outPath = path.join(dir, `${crypto.randomUUID()}.mp3`)

electron/main/ipc/handle.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { ipcMain, type IpcMainInvokeEvent } from 'electron'
2+
import type { ApiChannels } from '../../ipc-contract'
3+
4+
// Register a main-process handler typed against the IPC contract. The channel name must be a real
5+
// `group:method` from `Api`, and the handler's args and return type are derived from that method's
6+
// signature, so renaming a channel or changing a payload in the contract fails to compile here.
7+
type ChannelHandler<K extends keyof ApiChannels> = ApiChannels[K] extends (...args: infer A) => infer R
8+
? (event: IpcMainInvokeEvent, ...args: A) => R | Awaited<R>
9+
: never
10+
11+
export function handle<K extends keyof ApiChannels>(channel: K, handler: ChannelHandler<K>): void {
12+
ipcMain.handle(channel as string, handler as (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown)
13+
}

electron/main/ipc/library.ts

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { ipcMain, app } from 'electron'
1+
import { app } from 'electron'
22
import path from 'node:path'
33
import fs from 'node:fs'
4+
import { handle } from './handle'
45
import * as samples from '../db/queries/samples'
56

67
const AUDIO_EXTS = new Set(['wav', 'mp3', 'flac', 'aiff', 'aif', 'ogg', 'm4a'])
@@ -27,19 +28,19 @@ import { syncProjectChopsToLibrary } from '../services/materializeChops'
2728
import type { Sample, Project, ProjectRegion } from '../../types'
2829

2930
export function registerLibraryHandlers(): void {
30-
ipcMain.handle('library:getSamples', (_, filters?: { bpm?: number; key?: string; tags?: string[]; projectId?: string }) => {
31+
handle('library:getSamples', (_, filters?: { bpm?: number; key?: string; tags?: string[]; projectId?: string }) => {
3132
return samples.getAllSamples(filters)
3233
})
3334

34-
ipcMain.handle('library:addSample', (_, data: { name: string; filePath: string; duration?: number }) => {
35+
handle('library:addSample', (_, data: { name: string; filePath: string; duration?: number }) => {
3536
return samples.addSample(data)
3637
})
3738

38-
ipcMain.handle('library:updateSample', (_, id: string, data: Partial<Pick<Sample, 'name' | 'bpm' | 'musicalKey' | 'tags' | 'waveformData'>>) => {
39+
handle('library:updateSample', (_, id: string, data: Partial<Pick<Sample, 'name' | 'bpm' | 'musicalKey' | 'tags' | 'waveformData'>>) => {
3940
samples.updateSample(id, data)
4041
})
4142

42-
ipcMain.handle('library:importFolder', (_, folderPath: string) => {
43+
handle('library:importFolder', (_, folderPath: string) => {
4344
const existing = new Set(samples.getAllSamples().map((s) => s.filePath))
4445
const allFiles = scanAudioFiles(folderPath)
4546
const newFiles = allFiles.filter((f) => !existing.has(f))
@@ -50,14 +51,14 @@ export function registerLibraryHandlers(): void {
5051
return { imported: newFiles.length, skipped: allFiles.length - newFiles.length }
5152
})
5253

53-
ipcMain.handle('library:deleteSample', (_, id: string) => {
54+
handle('library:deleteSample', (_, id: string) => {
5455
const filePath = samples.deleteSample(id)
5556
if (filePath) {
5657
try { fs.unlinkSync(filePath) } catch { /* file already gone, ignore */ }
5758
}
5859
})
5960

60-
ipcMain.handle('library:saveChops', async (_, params: {
61+
handle('library:saveChops', async (_, params: {
6162
sourceFilePath: string
6263
regions: Array<{ start: number; end: number; name: string }>
6364
projectId?: string
@@ -90,40 +91,40 @@ export function registerLibraryHandlers(): void {
9091
return saved
9192
})
9293

93-
ipcMain.handle('projects:getAll', () => {
94+
handle('projects:getAll', () => {
9495
return projects.getAllProjects()
9596
})
9697

97-
ipcMain.handle('projects:get', (_, id: string) => {
98+
handle('projects:get', (_, id: string) => {
9899
return projects.getProject(id)
99100
})
100101

101-
ipcMain.handle('projects:save', async (_, data: { name: string; sourcePath: string | null; sourceName?: string | null; source?: 'local' | 'freesound'; regions: ProjectRegion[] }) => {
102+
handle('projects:save', async (_, data: { name: string; sourcePath: string | null; sourceName?: string | null; source?: 'local' | 'freesound'; regions: ProjectRegion[] }) => {
102103
const project = projects.saveProject(data)
103104
await syncProjectChopsToLibrary(project.id)
104105
return project
105106
})
106107

107-
ipcMain.handle('projects:update', async (_, id: string, data: Partial<Pick<Project, 'name' | 'sourcePath' | 'regions'>>) => {
108+
handle('projects:update', async (_, id: string, data: Partial<Pick<Project, 'name' | 'sourcePath' | 'regions'>>) => {
108109
projects.updateProject(id, data)
109110
if (data.regions !== undefined) await syncProjectChopsToLibrary(id)
110111
})
111112

112-
ipcMain.handle('projects:getChops', (_, projectId: string) => {
113+
handle('projects:getChops', (_, projectId: string) => {
113114
return projects.getProjectChops(projectId)
114115
})
115116

116-
ipcMain.handle('projects:getAllChops', () => {
117+
handle('projects:getAllChops', () => {
117118
return projects.getAllProjectChops()
118119
})
119120

120-
ipcMain.handle('projects:upsertChops', async (_, projectId: string, regions: ProjectRegion[]) => {
121+
handle('projects:upsertChops', async (_, projectId: string, regions: ProjectRegion[]) => {
121122
const chops = projects.upsertProjectChops(projectId, regions)
122123
await syncProjectChopsToLibrary(projectId)
123124
return chops
124125
})
125126

126-
ipcMain.handle('projects:delete', (_, id: string) => {
127+
handle('projects:delete', (_, id: string) => {
127128
// The library is a projection of projects, so a deleted project's materialized chops leave the
128129
// library too (rows + files). Pack slots that referenced them are left intact — packs are
129130
// independent snapshots.
@@ -137,21 +138,21 @@ export function registerLibraryHandlers(): void {
137138
projects.deleteProject(id)
138139
})
139140

140-
ipcMain.handle('projects:duplicate', async (_, id: string) => {
141+
handle('projects:duplicate', async (_, id: string) => {
141142
const project = projects.duplicateProject(id)
142143
if (project) await syncProjectChopsToLibrary(project.id)
143144
return project
144145
})
145146

146-
ipcMain.handle('library:getPackSlotRefCount', (_, id: string) => {
147+
handle('library:getPackSlotRefCount', (_, id: string) => {
147148
return samples.getSamplePackSlotRefCount(id)
148149
})
149150

150-
ipcMain.handle('library:getOrphans', () => {
151+
handle('library:getOrphans', () => {
151152
return samples.getAllSamples().filter((s) => !fs.existsSync(s.filePath))
152153
})
153154

154-
ipcMain.handle('library:deleteOrphans', (_, ids: string[]) => {
155+
handle('library:deleteOrphans', (_, ids: string[]) => {
155156
for (const id of ids) samples.deleteSample(id)
156157
return { deleted: ids.length }
157158
})

0 commit comments

Comments
 (0)