diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77306a8..57273bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,4 +22,7 @@ jobs: - run: pnpm tsc - run: pnpm lint - run: pnpm test + # Build the renderer so a broken import/asset fails CI instead of shipping to a release tag + # (F37). electron-builder is skipped here — it only runs in the release workflow. + - run: pnpm exec vite build - run: pnpm audit --audit-level=high --prod diff --git a/.harness/qa/adversarial-audit-2026-07-03.md b/.harness/qa/adversarial-audit-2026-07-03.md new file mode 100644 index 0000000..ff0037a --- /dev/null +++ b/.harness/qa/adversarial-audit-2026-07-03.md @@ -0,0 +1,744 @@ +-----BEGIN AGE ENCRYPTED FILE----- +YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHNzaC1lZDI1NTE5IExRMGNCZyBVWm9O +Zy9XSUtmL2Vsb0hIUkEvQlMzQUZGT1ZxcjQ0WUFCNnlXTW1SV3pFClBNbVMyRmwr +OGd3TmlJVUw4bnJLOE8zTEFBTUdZVjVXZE42WnFJOVlsMVEKLS0tIGRpYWFSWDJh +S3lsYmlQUUxFUHhWN3o0NUNXZnhqdzRDV210eGVGckV1amMKfM/Pybh2lm4AkU2j +ejM7iCMd0xR6BN+1GvLYmqPv2tXyhzRWnHAjnzfpCH/habKwqb1EWwikTslK2ay1 +KDeDepP7z/kZasC/EAmhTDd28GscclUeSOBU4ntjSFQ3Cs8ciDISL9bUhhffbNXN +ln9+4c3jVZbSjzxpRpBZ86sDz1ArrJUdakXR/euKJy1/MMwLBoigA/mg8bC5w8r6 +uQpMuBGm8Rnk/cjAoX90B6jqAJs3poTUbBED3DoyR6LKncdVd1W75Kq/rLSDwT3H +lOom0T/oVuJA0Y1nYzCtFEXg/m/2fW2pwH4mWW3nZiitzY1zLBEJQDWJzy3BK/m5 +WvBzZzGvmPvcdgI52rehtJ1fftpiP/gkBK00ubwx9z4Nh5z9jp5VUXQz/T9gBMuC +GGGfuVaXBQaLOL4Oxy1syUBYfMHSBeuNMP3gq2Fl9C+1ocGDM0nL8hJ6y4eelIbQ +yt99IktZyNKh2O0qiSTVvVhhg+IoZZMJzmE1KSxa9RZUALy1vTUSfiScEfe3tz66 +Dl18ND5t9H9UDkIPkxoWg1sgvZI/lh2Sh8ExRo1N2AqZze2m0rAkGvBqG1ffRpRV +msxTGkrs7vjM6tcPA4K9ryJ11B1CCOqtHUA56RHUd2p3IkPmMVh7P+vFmWalcP47 +Xc1HTuUUBR3ao5YS+3Pcy/QQlprkMncf+uomW4MiCEOl+bEACIcmV6aZoqQWQ3em +LxHsAzpioQu/14kQbPjqyI43qTXsZAjJLRHW//Qn+X5MEh7pTS5XTUSj+3bPwfpB +1xNROEP9r6v1AVw4+FYM2AHGWR2m0wYY9PgOSTJuWkQcDRv6m46lQoHKZSTp57cO +QS6C7dBCUoBbSKT/M694qDrppb3WkYD85UFJfcKH/Bet8LrKMgRhccDXZXGAvLjV +AKIRZ1f4OQ4qy10iPG6xK+aymgqf+xTfUiA575n14He4mfZphU/PiIuBJLGEPYLy +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 +-----END AGE ENCRYPTED FILE----- diff --git a/AGENTS.md b/AGENTS.md index 4998ec6..78f36df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ **Freesound API key:** Set inside the app's Settings UI (not a `.env` file). Stored at `$USERDATA/settings.json` (`app.getPath('userData')`). Only needed to use Freesound search inside the app — not required for seeding. -**Seed script:** Run `pnpm dev` at least once first (creates the SQLite database), then `pnpm seed`. Audio files are bundled in `scripts/seed-audio/` — no API key or network access needed. userData is always at `~/Library/Application Support/samplebyte/` in both dev and production builds (prior to the `app.setName()` fix, dev mode used `~/Library/Application Support/Electron/` instead). +**Seed script:** Run `pnpm dev` at least once first (creates the SQLite database), then `pnpm seed`. Audio files are bundled in `scripts/seed-audio/` — no API key or network access needed. On macOS userData is at `~/Library/Application Support/samplebyte/` in both dev and production (prior to the `app.setName()` fix, dev mode used `~/Library/Application Support/Electron/`); on Linux it's `~/.config/samplebyte/` and on Windows `%APPDATA%\samplebyte\`. The seed script checks all three and only clears its own `seed-`prefixed rows, so it won't wipe your dev library. **Stem separation model:** The Chop tab's Stems tool needs the vendored demucs WASM (`demucs.js` / `.wasm` / `.data`, ~85MB, MIT, originally from `uzstudio/free-music-demixer`). Run `pnpm fetch:stem-model` to download it into `public/stem-model/` (gitignored). The files are mirrored as assets on our own `stem-model-v1` GitHub release so the build does not depend on the upstream repo staying online; if those binaries ever need replacing, upload new assets there and update `BASE` in `scripts/fetch-stem-model.mjs`. Required before `pnpm build` if you want the feature in the packaged app; without it the tool reports a clear "run pnpm fetch:stem-model" error and the rest of the app is unaffected. The model is the 4-source variant (Drums/Bass/Other/Vocals) and runs single-threaded in a renderer web worker (slow: ~6.6× realtime, cached per source). Packaged CSP allows `'unsafe-eval'` because the Emscripten build is loaded from text and instantiates WebAssembly. diff --git a/README.md b/README.md index e536486..a9fcc4b 100644 --- a/README.md +++ b/README.md @@ -41,9 +41,11 @@ This is not a commercial product. It's open source, free to use, and built in my |---|---|---|---| | Maschine MK3 | WAV | 44.1 kHz | 16-bit | | Roland SP-404 MkII | WAV | 48 kHz | 16-bit | -| Akai MPC (generic) | WAV | 44.1 kHz | 24-bit | +| Akai MPC One | WAV | 44.1 kHz | 24-bit | | Generic WAV | WAV | 44.1 kHz | 24-bit | +Every profile exports up to 16 pads today. + Adding a new hardware target is just one config object in `electron/main/hardware/profiles.ts`, no other changes needed. --- @@ -51,7 +53,7 @@ Adding a new hardware target is just one config object in `electron/main/hardwar ## Audio Sources - **Local files:** drag and drop any audio file (WAV, MP3, FLAC, AIFF, OGG) -- **Freesound:** search 650,000+ Creative Commons sounds directly in the app — category shortcut chips (Kick, Snare, Hi-Hat, 808, etc.) on the empty state let you jump in fast, and sort/duration pill filters re-run the query instantly +- **Freesound:** search 650,000+ Creative Commons sounds directly in the app — category shortcut chips (Kick, Snare, Hi-Hat, 808, etc.) on the empty state let you jump in fast, and sort/duration pill filters re-run the query instantly. Loading a result opens the high-quality **preview** (~128 kbps MP3) in Chop; original-quality downloads (which need OAuth) are a planned follow-up. License and author are stored with any chops you keep and written to a `credits.txt` when you export a pack, so you can honour CC attribution. No YouTube. Not because it isn't useful (the original version of this app was built around it), but building on top of bypassing another platform's ToS is a dead end. Freesound covers discovery legally and with solid content. Local files cover everything else. @@ -108,6 +110,23 @@ Click "More info" then "Run anyway" to get past the unsigned-app warning. | `⌘Z` / `⇧⌘Z` | Undo / Redo chop edits | | `Mouse wheel` | Zoom waveform | +On Windows and Linux, use `Ctrl` in place of `⌘` (the app shows the right label per platform). + +--- + +## Building from source + +Requires the current Node.js LTS and [pnpm](https://pnpm.io/). A native toolchain is needed for the `postinstall` step, which rebuilds `better-sqlite3` against Electron's ABI ([node-gyp prerequisites](https://github.com/nodejs/node-gyp#installation)). + +```bash +pnpm install # installs deps and runs electron-rebuild +pnpm dev # launch the app (creates the SQLite DB on first run) +pnpm test # vitest (audio-rendering suites run against real ffmpeg) +pnpm tsc && pnpm lint # typecheck + lint +``` + +The Chop tab's Stems tool needs a vendored model that is not in git; run `pnpm fetch:stem-model` once before `pnpm build` if you want that feature in a packaged build. `pnpm seed` loads demo projects/packs (run `pnpm dev` at least once first). See [AGENTS.md](AGENTS.md) for deeper contributor notes. + --- ## Acknowledgments diff --git a/electron/ipc-contract.ts b/electron/ipc-contract.ts index 297df4f..47f21a2 100644 --- a/electron/ipc-contract.ts +++ b/electron/ipc-contract.ts @@ -11,7 +11,6 @@ import type { ProjectRegion, ProjectChop, PackSourceItem, - ExportRegionsParams, FreesoundPage, StemPcm, StemFile, @@ -23,11 +22,6 @@ export type Api = { addSample: (data: { name: string; filePath: string; duration?: number }) => Promise updateSample: (id: string, data: Partial>) => Promise deleteSample: (id: string) => Promise - saveChops: (params: { - sourceFilePath: string - regions: Array<{ start: number; end: number; name: string }> - projectId?: string - }) => Promise importFolder: (folderPath: string) => Promise<{ imported: number; skipped: number }> getPackSlotRefCount: (id: string) => Promise getOrphans: () => Promise @@ -36,7 +30,7 @@ export type Api = { projects: { getAll: () => Promise get: (id: string) => Promise - save: (data: { name: string; sourcePath: string | null; sourceName?: string | null; source?: 'local' | 'freesound'; regions: ProjectRegion[] }) => Promise + save: (data: { name: string; sourcePath: string | null; sourceName?: string | null; source?: 'local' | 'freesound'; freesoundId?: string | null; license?: string | null; author?: string | null; regions: ProjectRegion[] }) => Promise update: (id: string, data: Partial>) => Promise getChops: (projectId: string) => Promise getAllChops: () => Promise> @@ -45,7 +39,6 @@ export type Api = { duplicate: (id: string) => Promise } audio: { - exportRegions: (params: ExportRegionsParams) => Promise<{ filesWritten: number }> trimSource: (params: { sourceFilePath: string; start: number; end: number }) => Promise<{ filePath: string; duration: number }> } stems: { @@ -58,6 +51,8 @@ export type Api = { } fs: { getPathForFile: (file: File) => string + // Broker a renderer-obtained path (drag-drop) so local-file:// will serve it. See F32. + allowPath: (filePath: string) => Promise pickFile: () => Promise pickFolder: () => Promise } @@ -81,11 +76,36 @@ export type Api = { removeSlot: (packId: string, slotNumber: number) => Promise rename: (id: string, name: string) => Promise delete: (id: string) => Promise - export: (packId: string, outputDir: string) => Promise<{ filesWritten: number }> + export: (packId: string, outputDir: string) => Promise<{ filesWritten: number; failed: number }> // Rebuild an orphaned chop pad's audio back into the library (from the pad's owned WAV) and // relink the pad to the new sample. Returns the created sample. regenerateSlotToLibrary: (packId: string, slotNumber: number) => Promise } + updates: { + check: () => Promise + download: () => Promise + install: () => Promise + } +} + +// Result of an update check. `available` is false in dev (nothing to update); `error` carries a +// network/check failure message. +export type UpdateCheckResult = + | { available: boolean; version: string; newVersion?: string } + | { error: string } + +// Main -> renderer push events. Kept separate from the invoke groups above so it stays out of the +// ApiChannels flattening (these are ipcRenderer.on subscriptions, not invoke handlers). Each method +// registers a listener and returns an unsubscribe function. +export type ApiEvents = { + // Fired after a background startup task (chop backfill) changes the library so the UI can refetch. + onLibraryChanged: (cb: () => void) => () => void + // Auto-update lifecycle (F18): a newer version is available, download progress (0..100), the + // download finished (ready to install), or the updater errored. + onUpdateAvailable: (cb: (info: { version: string; newVersion?: string }) => void) => () => void + onUpdateProgress: (cb: (percent: number) => void) => () => void + onUpdateDownloaded: (cb: () => void) => () => void + onUpdateError: (cb: (message: string) => void) => () => void } type UnionToIntersection = (U extends unknown ? (k: U) => void : never) extends (k: infer I) => void ? I : never diff --git a/electron/main/audio/waveform.test.ts b/electron/main/audio/waveform.test.ts new file mode 100644 index 0000000..41f2f94 --- /dev/null +++ b/electron/main/audio/waveform.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import os from 'node:os' +import path from 'node:path' +import fs from 'node:fs' +import { fileURLToPath } from 'node:url' +import { renderClip, LIBRARY_FORMAT } from '../services/render' +import { extractWaveformData, readWavDuration } from './waveform' +import { readWavInfo } from '../../../test/wav' + +const FIXTURE = fileURLToPath(new URL('../../../scripts/seed-audio/amen-break.mp3', import.meta.url)) + +let tmpDir: string +let clip: string +beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waveform-test-')) + clip = path.join(tmpDir, 'clip.wav') + await renderClip(FIXTURE, { start: 0.25, end: 0.75 }, clip, LIBRARY_FORMAT) +}) +afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) +}) + +describe('readWavDuration', () => { + it('matches the real decoded length from the header (F9)', () => { + const duration = readWavDuration(clip) + expect(duration).not.toBeNull() + // Agrees with the independent test parser rather than the requested trim span. + expect(duration!).toBeCloseTo(readWavInfo(clip).duration, 3) + }) + + it('returns null for a non-WAV file', () => { + const junk = path.join(tmpDir, 'junk.bin') + fs.writeFileSync(junk, Buffer.from('not a wav file at all')) + expect(readWavDuration(junk)).toBeNull() + }) +}) + +describe('extractWaveformData', () => { + it('returns the requested number of bars for a real clip', () => { + const bars = extractWaveformData(clip, 100) + expect(bars).toHaveLength(100) + expect(bars.some((b) => b > 0)).toBe(true) + }) + + it('returns zeros (not garbage) when there is no data chunk (F10)', () => { + const junk = path.join(tmpDir, 'nodata.bin') + fs.writeFileSync(junk, Buffer.from('garbage bytes with no riff header')) + expect(extractWaveformData(junk, 8)).toEqual(new Array(8).fill(0)) + }) +}) diff --git a/electron/main/audio/waveform.ts b/electron/main/audio/waveform.ts index 669a4aa..258333f 100644 --- a/electron/main/audio/waveform.ts +++ b/electron/main/audio/waveform.ts @@ -1,27 +1,52 @@ import fs from 'node:fs' -// Reads a s16 stereo WAV (as produced by renderClip in LIBRARY_FORMAT) and returns ~100 peak amplitude values. -export function extractWaveformData(filePath: string, bars = 100): number[] { - const buf = fs.readFileSync(filePath) +type WavChunk = { dataOffset: number; dataSize: number; sampleRate: number; channels: number; bitsPerSample: number } - // Walk chunks to find 'data' +// Walk the RIFF chunk list, honouring the format's word-alignment (odd-size chunks are padded to an +// even boundary) so a stray odd chunk before 'data' can't desync the parser. Returns null when the +// file has no readable fmt/data pair rather than treating trailing garbage as samples (F10). +function parseWav(buf: Buffer): WavChunk | null { + if (buf.length < 12 || buf.toString('ascii', 0, 4) !== 'RIFF' || buf.toString('ascii', 8, 12) !== 'WAVE') return null + + let sampleRate = 0 + let channels = 0 + let bitsPerSample = 0 let offset = 12 - while (offset < buf.length - 8) { + while (offset + 8 <= buf.length) { const chunkId = buf.toString('ascii', offset, offset + 4) const chunkSize = buf.readUInt32LE(offset + 4) - if (chunkId === 'data') { offset += 8; break } - offset += 8 + chunkSize + const body = offset + 8 + if (chunkId === 'fmt ' && body + 16 <= buf.length) { + channels = buf.readUInt16LE(body + 2) + sampleRate = buf.readUInt32LE(body + 4) + bitsPerSample = buf.readUInt16LE(body + 14) + } else if (chunkId === 'data') { + const dataSize = Math.min(chunkSize, buf.length - body) + if (!sampleRate || !channels || !bitsPerSample) return null + return { dataOffset: body, dataSize, sampleRate, channels, bitsPerSample } + } + // Chunks are word-aligned: an odd size is followed by a single pad byte. + offset = body + chunkSize + (chunkSize & 1) } + return null +} + +// Reads a s16 stereo WAV (as produced by renderClip in LIBRARY_FORMAT) and returns ~100 peak amplitude values. +export function extractWaveformData(filePath: string, bars = 100): number[] { + const buf = fs.readFileSync(filePath) + const wav = parseWav(buf) + if (!wav) return new Array(bars).fill(0) const bytesPerFrame = 4 // s16 stereo - const totalFrames = Math.floor((buf.length - offset) / bytesPerFrame) + const dataEnd = wav.dataOffset + wav.dataSize + const totalFrames = Math.floor(wav.dataSize / bytesPerFrame) const framesPerBar = Math.max(1, Math.floor(totalFrames / bars)) const result: number[] = [] for (let i = 0; i < bars; i++) { let peak = 0 - const start = offset + i * framesPerBar * bytesPerFrame - const end = Math.min(start + framesPerBar * bytesPerFrame, buf.length - 1) + const start = wav.dataOffset + i * framesPerBar * bytesPerFrame + const end = Math.min(start + framesPerBar * bytesPerFrame, dataEnd - 1) for (let j = start; j < end; j += 2) { const v = Math.abs(buf.readInt16LE(j)) / 32767 if (v > peak) peak = v @@ -31,3 +56,13 @@ export function extractWaveformData(filePath: string, bars = 100): number[] { return result } + +// Real decoded duration in seconds from the WAV header, so library samples record what was actually +// written rather than the requested trim span (F9). Returns null for an unreadable file. +export function readWavDuration(filePath: string): number | null { + const wav = parseWav(fs.readFileSync(filePath)) + if (!wav) return null + const bytesPerFrame = wav.channels * (wav.bitsPerSample / 8) + if (bytesPerFrame <= 0) return null + return wav.dataSize / bytesPerFrame / wav.sampleRate +} diff --git a/electron/main/db/index.ts b/electron/main/db/index.ts index ed3684c..98ae886 100644 --- a/electron/main/db/index.ts +++ b/electron/main/db/index.ts @@ -78,11 +78,30 @@ function runMigrations(): void { if (!sampleCols.includes('source_chop_id')) { db.exec('ALTER TABLE samples ADD COLUMN source_chop_id TEXT') } + // File ownership: 1 only for files the app rendered/copied into userData/samples, which deleting + // the row is allowed to unlink. Import-in-place originals and shared stem-cache files stay 0 so + // deleteSample never destroys a user's own file (F1/T1). Backfill by path prefix: everything the + // app has ever exclusively owned lives under userData/samples. + if (!sampleCols.includes('owned')) { + db.exec('ALTER TABLE samples ADD COLUMN owned INTEGER NOT NULL DEFAULT 0') + const samplesDir = path.join(app.getPath('userData'), 'samples') + path.sep + db.prepare('UPDATE samples SET owned = 1 WHERE file_path LIKE ?').run(`${samplesDir}%`) + } + // Freesound attribution carried onto materialized samples so exported packs can credit CC sources (F24). + if (!sampleCols.includes('license')) db.exec('ALTER TABLE samples ADD COLUMN license TEXT') + if (!sampleCols.includes('author')) db.exec('ALTER TABLE samples ADD COLUMN author TEXT') // Null out references to projects that no longer exist. project_id is a soft link with no FK, // so deleted projects (and seed artifacts) leave samples pointing at ghost ids; the library // reads these as "—". Project deletion now nulls these itself, so this is a one-time cleanup. db.exec('UPDATE samples SET project_id = NULL WHERE project_id IS NOT NULL AND project_id NOT IN (SELECT id FROM projects)') + // Mark chops whose source can't be materialized (missing/unreadable file) so the one-time backfill + // stops retrying them on every launch forever (F14). + const chopCols = (db.prepare('PRAGMA table_info(project_chops)').all() as { name: string }[]).map((c) => c.name) + if (!chopCols.includes('materialize_failed')) { + db.exec('ALTER TABLE project_chops ADD COLUMN materialize_failed INTEGER NOT NULL DEFAULT 0') + } + const projectCols = (db.prepare('PRAGMA table_info(projects)').all() as { name: string }[]).map((c) => c.name) if (!projectCols.includes('source_name')) { db.exec('ALTER TABLE projects ADD COLUMN source_name TEXT') @@ -90,6 +109,19 @@ function runMigrations(): void { if (!projectCols.includes('source')) { db.exec("ALTER TABLE projects ADD COLUMN source TEXT NOT NULL DEFAULT 'local'") } + // Freesound attribution for the project source (F24). + if (!projectCols.includes('freesound_id')) db.exec('ALTER TABLE projects ADD COLUMN freesound_id TEXT') + if (!projectCols.includes('license')) db.exec('ALTER TABLE projects ADD COLUMN license TEXT') + if (!projectCols.includes('author')) db.exec('ALTER TABLE projects ADD COLUMN author TEXT') + + // Collapse any duplicate chop-sample rows left by the pre-serialization sync race (F4): keep the + // most recent row per source_chop_id and drop the rest. Their now-orphaned WAVs are reclaimed by + // the startup GC sweep. Must run before the UNIQUE index below can be created. + db.exec(` + DELETE FROM samples + WHERE source_chop_id IS NOT NULL + AND rowid NOT IN (SELECT MAX(rowid) FROM samples WHERE source_chop_id IS NOT NULL GROUP BY source_chop_id) + `) migrateProjectRegionsToChops() migratePackSlotsToSnapshots() @@ -111,6 +143,10 @@ function runMigrations(): void { CREATE INDEX IF NOT EXISTS idx_pack_slots_sample_id ON pack_slots(sample_id); CREATE INDEX IF NOT EXISTS idx_samples_project_id ON samples(project_id); CREATE INDEX IF NOT EXISTS idx_samples_source_chop_id ON samples(source_chop_id); + -- One materialized sample per source chop. Partial so the many source_chop_id=NULL rows + -- (imported/freesound/local samples) are unconstrained; enforces the projection invariant that + -- serialization already upholds (F4). + CREATE UNIQUE INDEX IF NOT EXISTS idx_samples_source_chop_id_unique ON samples(source_chop_id) WHERE source_chop_id IS NOT NULL; `) } diff --git a/electron/main/db/queries/packs.ts b/electron/main/db/queries/packs.ts index 99de722..353abbe 100644 --- a/electron/main/db/queries/packs.ts +++ b/electron/main/db/queries/packs.ts @@ -25,8 +25,6 @@ function deserializeSlot(row: Record): PackSlot { end: row.end as number | null, displayName: row.display_name as string, sourceChopUpdatedAt: row.source_chop_updated_at as number | null, - pitchShiftSemitones: row.pitch_shift_semitones as number | null, - timeStretchRatio: row.time_stretch_ratio as number | null, audioPath: row.audio_path as string | null, } } @@ -92,10 +90,8 @@ export function upsertSlot(packId: string, slotNumber: number, source: PackSourc end, display_name, source_chop_updated_at, - pitch_shift_semitones, - time_stretch_ratio, audio_path - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `) .run( packId, @@ -109,8 +105,6 @@ export function upsertSlot(packId: string, slotNumber: number, source: PackSourc source.end, source.displayName, source.sourceChopUpdatedAt, - null, - null, audioPath ) } diff --git a/electron/main/db/queries/projects.ts b/electron/main/db/queries/projects.ts index 3fb53d4..e9c8765 100644 --- a/electron/main/db/queries/projects.ts +++ b/electron/main/db/queries/projects.ts @@ -9,6 +9,9 @@ function deserialize(row: Record, chops?: ProjectChop[]): Proje sourcePath: row.source_path as string | null, sourceName: row.source_name as string | null, source: (row.source as 'local' | 'freesound') || 'local', + freesoundId: row.freesound_id as string | null, + license: row.license as string | null, + author: row.author as string | null, regions: chops ?? getProjectChops(id), createdAt: row.created_at as number, } @@ -49,11 +52,14 @@ export function getProject(id: string): Project | null { return row ? deserialize(row) : null } -export function saveProject(data: { name: string; sourcePath: string | null; sourceName?: string | null; source?: 'local' | 'freesound'; regions: ProjectRegion[] }): Project { +export function saveProject(data: { name: string; sourcePath: string | null; sourceName?: string | null; source?: 'local' | 'freesound'; freesoundId?: string | null; license?: string | null; author?: string | null; regions: ProjectRegion[] }): Project { const db = getDb() const id = crypto.randomUUID() const createdAt = Date.now() const source = data.source ?? 'local' + const freesoundId = data.freesoundId ?? null + const license = data.license ?? null + const author = data.author ?? null const regions = data.regions.map((region, index) => ({ ...region, id: region.id ?? crypto.randomUUID(), @@ -63,13 +69,18 @@ export function saveProject(data: { name: string; sourcePath: string | null; sou updatedAt: createdAt, })) - db.prepare('INSERT INTO projects (id, name, source_path, source_name, source, regions, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)').run( + // The canonical chop store is project_chops (below); the legacy `regions` JSON column is only ever + // read by the one-time migrateProjectRegionsToChops backfill, so new rows leave it at its '[]' + // default rather than maintaining a second, drift-prone copy of the same data (F19c). + db.prepare('INSERT INTO projects (id, name, source_path, source_name, source, freesound_id, license, author, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)').run( id, data.name, data.sourcePath ?? null, data.sourceName ?? null, source, - JSON.stringify(regions.map(({ id, start, end, name }) => ({ id, start, end, name }))), + freesoundId, + license, + author, createdAt ) @@ -84,7 +95,7 @@ export function saveProject(data: { name: string; sourcePath: string | null; sou }) tx() - return { id, name: data.name, sourcePath: data.sourcePath, sourceName: data.sourceName ?? null, source, regions, createdAt } + return { id, name: data.name, sourcePath: data.sourcePath, sourceName: data.sourceName ?? null, source, freesoundId, license, author, regions, createdAt } } export function updateProject(id: string, data: Partial>): void { @@ -96,16 +107,9 @@ export function updateProject(id: string, data: Partial ({ - id: region.id, - start: region.start, - end: region.end, - name: region.name || `Chop ${index + 1}`, - updatedAt: 'updatedAt' in region ? region.updatedAt : now, - })))) } if (fields.length === 0) return @@ -132,6 +136,9 @@ export function duplicateProject(id: string): Project | null { sourcePath: original.sourcePath, sourceName: original.sourceName, source: original.source, + freesoundId: original.freesoundId, + license: original.license, + author: original.author, // Drop the source chop ids so the copy gets fresh ones — reusing them violates the // project_chops primary key (the originals still exist). regions: original.regions.map((r) => ({ start: r.start, end: r.end, name: r.name })), @@ -144,10 +151,11 @@ export function getProjectChops(projectId: string): ProjectChop[] { .all(projectId) as Record[]).map(deserializeChop) } -export function getAllProjectChops(): Array { +export function getAllProjectChops(): Array { return (getDb() .prepare(` - SELECT project_chops.*, projects.name as project_name, projects.source_path, projects.source + SELECT project_chops.*, projects.name as project_name, projects.source_path, projects.source, + projects.freesound_id, projects.license, projects.author FROM project_chops JOIN projects ON projects.id = project_chops.project_id ORDER BY projects.created_at DESC, project_chops.start ASC @@ -157,9 +165,19 @@ export function getAllProjectChops(): Array & Partial> +type NewSample = Pick & Partial> function deserialize(row: Record): Sample { return { @@ -22,9 +22,12 @@ function deserialize(row: Record): Sample { tags: JSON.parse((row.tags as string) || '[]'), source: (row.source as Sample['source']) || 'local', freesoundId: row.freesound_id as string | null, + license: row.license as string | null, + author: row.author as string | null, waveformData: row.waveform_data ? JSON.parse(row.waveform_data as string) : null, projectId: row.project_id as string | null, sourceChopId: row.source_chop_id as string | null, + owned: (row.owned as number) === 1, createdAt: row.created_at as number, } } @@ -34,6 +37,13 @@ export function getSample(id: string): Sample | null { return row ? deserialize(row) : null } +// The materialized sample for a project chop, if any. Used to resolve attribution for chop-based +// pack pads (which reference a chop, not a sample row) when writing export credits (F24). +export function getSampleBySourceChopId(chopId: string): Sample | null { + const row = getDb().prepare('SELECT * FROM samples WHERE source_chop_id = ? LIMIT 1').get(chopId) as Record | undefined + return row ? deserialize(row) : null +} + export function getAllSamples(filters?: SampleFilters): Sample[] { const db = getDb() @@ -74,8 +84,8 @@ export function addSample(data: NewSample): Sample { const createdAt = Date.now() db.prepare(` - INSERT INTO samples (id, name, file_path, duration, bpm, musical_key, tags, source, freesound_id, waveform_data, project_id, source_chop_id, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO samples (id, name, file_path, duration, bpm, musical_key, tags, source, freesound_id, license, author, waveform_data, project_id, source_chop_id, owned, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( id, data.name, @@ -86,15 +96,39 @@ export function addSample(data: NewSample): Sample { JSON.stringify(data.tags ?? []), data.source ?? 'local', data.freesoundId ?? null, + data.license ?? null, + data.author ?? null, data.waveformData ? JSON.stringify(data.waveformData) : null, data.projectId ?? null, data.sourceChopId ?? null, + data.owned ? 1 : 0, createdAt ) return getSample(id)! } +// Bulk-register imported-in-place local files in one transaction (fast even for large folders) and +// skip any whose path already exists (UNIQUE file_path). owned=0: these are the user's own files, so +// deleting the row must never unlink them (F1). Returns how many rows were actually inserted (F16). +export function importLocalFiles(files: { name: string; filePath: string }[]): number { + const db = getDb() + const insert = db.prepare(` + INSERT OR IGNORE INTO samples (id, name, file_path, tags, source, owned, created_at) + VALUES (?, ?, ?, '[]', 'local', 0, ?) + `) + const tx = db.transaction((rows: { name: string; filePath: string }[]) => { + let inserted = 0 + const now = Date.now() + for (const row of rows) { + const result = insert.run(crypto.randomUUID(), row.name, row.filePath, now) + inserted += result.changes + } + return inserted + }) + return tx(files) +} + // Chop ids that have already been materialized into a sample. Backs the idempotency // guard for the one-time chop materialization pass. export function getMaterializedChopIds(): Set { @@ -123,12 +157,16 @@ export function updateSample(id: string, data: Partial string } @@ -16,28 +19,28 @@ export const profiles: HardwareProfile[] = [ id: 'sp404-mkii', name: 'Roland SP-404 MkII', padCount: 16, - format: { container: 'wav', sampleRate: 48000, bitDepth: 16, sampleFmt: 's16' }, + format: { container: 'wav', sampleRate: 48000, sampleFmt: 's16' }, fileName: (slot, name) => `${String(slot + 1).padStart(3, '0')}_${sanitize(name)}.wav`, }, { id: 'mpc-generic', name: 'Akai MPC One', padCount: 16, - format: { container: 'wav', sampleRate: 44100, bitDepth: 24, sampleFmt: 's24' }, + format: { container: 'wav', sampleRate: 44100, sampleFmt: 's24' }, fileName: (_, name) => `${sanitize(name)}.wav`, }, { id: 'maschine-mk3', name: 'Maschine MK3', padCount: 16, - format: { container: 'wav', sampleRate: 44100, bitDepth: 16, sampleFmt: 's16' }, + format: { container: 'wav', sampleRate: 44100, sampleFmt: 's16' }, fileName: (slot, name) => `${String(slot + 1).padStart(2, '0')}_${sanitize(name)}.wav`, }, { id: 'generic', name: 'Generic WAV', - padCount: 128, - format: { container: 'wav', sampleRate: 44100, bitDepth: 24, sampleFmt: 's24' }, + padCount: 16, + format: { container: 'wav', sampleRate: 44100, sampleFmt: 's24' }, fileName: (_, name) => `${sanitize(name)}.wav`, }, ] diff --git a/electron/main/index.ts b/electron/main/index.ts index 453f48d..bf891fe 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -3,9 +3,12 @@ import { release } from 'node:os' import { dirname, join } from 'node:path' import { appendFileSync, existsSync, mkdirSync } from 'node:fs' import { fileURLToPath, pathToFileURL } from 'node:url' -import { update } from './update' +import { registerUpdateHandlers } from './update' import { initDatabase } from './db/index' import { materializeProjectChops } from './services/materializeChops' +import { isLocalFileAllowed } from './services/localFileAccess' +import { gcOrphanedFiles } from './services/gc' +import { isFfmpegAvailable } from './services/ffmpeg' import { handle } from './ipc/handle' import { registerLibraryHandlers } from './ipc/library' import { registerAudioHandlers } from './ipc/audio' @@ -96,7 +99,9 @@ protocol.registerSchemesAsPrivileged([ }, ]) -if (release().startsWith('6.1')) app.disableHardwareAcceleration() +// Windows 7 (NT 6.1) needs hardware acceleration disabled. Guard on win32 too: the bare version +// check also matched the Linux 6.1.x LTS kernel and needlessly disabled acceleration there (F23). +if (process.platform === 'win32' && release().startsWith('6.1')) app.disableHardwareAcceleration() // In dev mode the Electron binary runs without a bundle, so app.getName() returns // "Electron" instead of "samplebyte", which puts userData in the wrong directory. @@ -176,16 +181,30 @@ async function createWindow() { return { action: 'deny' } }) - update(win) + registerUpdateHandlers(win) } app.whenReady().then(async () => { // Serve local audio files to the renderer without cross-origin restrictions protocol.handle('local-file', async (request) => { const url = new URL(request.url) - const filePath = decodeURIComponent( + let filePath = decodeURIComponent( url.hostname ? `/${url.hostname}${url.pathname}` : url.pathname ) + // toLocalFileUrl encodes Windows paths with an empty authority as /C:/Users/...; strip the + // leading slash before the drive letter so it's a real Windows path (F34). No-op on POSIX. + if (/^\/[A-Za-z]:\//.test(filePath)) filePath = filePath.slice(1) + // Deny anything the app has no legitimate reason to serve — this protocol is CORS-open and + // CSP-bypassing, so an unscoped handler is an arbitrary-file-read primitive (F32). + if (!isLocalFileAllowed(filePath)) { + return new Response('Forbidden', { + status: 403, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS', + }, + }) + } // pathToFileURL properly percent-encodes spaces and special chars (e.g. paths under // "Application Support"). Plain `file://${filePath}` breaks on macOS userData paths. if (!existsSync(filePath)) { @@ -246,10 +265,32 @@ app.whenReady().then(async () => { if (url.startsWith('https:')) shell.openExternal(url) }) - initDatabase() - // One-time: materialize existing project chops into real library samples before the window - // loads, so Browse shows them as files. No-op (one COUNT query) on every later launch. - await materializeProjectChops().catch((error) => logMain('materializeProjectChops:failed', error)) + // Preflight the database: a corrupt/locked file or a WAL left by a half-alive second instance + // surfaces here. Without a library there is nothing to run, so fail with a recovery hint (F17). + try { + initDatabase() + } catch (error) { + showFatalError( + 'SampleByte could not open its library', + `${serializeError(error)}\n\nThe library database may be damaged or locked by another copy of SampleByte. ` + + `Quit any other instances and relaunch. Your audio files are safe under:\n${app.getPath('userData')}` + ) + app.quit() + return + } + + // ffmpeg drives every render/export; if the bundled binary is missing (unsupported arch) say so + // once rather than letting each operation fail cryptically later (F17). + if (!isFfmpegAvailable()) { + logMain('ffmpeg:missing') + try { + dialog.showErrorBox( + 'Audio processing unavailable', + 'The bundled ffmpeg binary was not found for this platform, so chopping, export, and stem saving will not work. Reinstall SampleByte for your platform.' + ) + } catch { /* headless */ } + } + registerLibraryHandlers() registerAudioHandlers() registerFilesystemHandlers() @@ -258,8 +299,28 @@ app.whenReady().then(async () => { registerFreesoundHandlers() registerStemsHandlers() createWindow() + + // Run startup maintenance AFTER the window exists so a large one-time chop backfill can't block + // first paint (F14). A user upgrading with hundreds of legacy chops now sees the splash and UI + // immediately; the library refreshes itself when the backfill finishes. + void runStartupMaintenance() }) +async function runStartupMaintenance(): Promise { + try { + const { removed } = gcOrphanedFiles() + if (removed > 0) logMain('gc:removed', String(removed)) + } catch (error) { + logMain('gc:failed', error) + } + try { + const materialized = await materializeProjectChops() + if (materialized > 0) win?.webContents.send('library:changed') + } catch (error) { + logMain('materializeProjectChops:failed', error) + } +} + app.on('window-all-closed', () => { win = null if (process.platform !== 'darwin') app.quit() diff --git a/electron/main/ipc/audio.ts b/electron/main/ipc/audio.ts index a70fdd3..913a22f 100644 --- a/electron/main/ipc/audio.ts +++ b/electron/main/ipc/audio.ts @@ -1,25 +1,8 @@ import { handle } from './handle' -import { getProfile } from '../hardware/profiles' -import { exportClips, type ExportClip } from '../services/export' import { trimSourceToCache } from '../services/trim' -import type { ExportRegionsParams, TrimSourceParams } from '../../types' +import type { TrimSourceParams } from '../../types' export function registerAudioHandlers(): void { - handle('audio:exportRegions', async (_, params: ExportRegionsParams) => { - const { regions, sourceFilePath, outputDir, profileId } = params - const profile = getProfile(profileId) - - const clips: ExportClip[] = regions.map((region, index) => ({ - sourcePath: sourceFilePath, - slotNumber: index, - name: region.name || `sample_${index + 1}`, - start: region.start, - end: region.end, - })) - - return exportClips(profile, clips, outputDir) - }) - handle('audio:trimSource', async (_, params: TrimSourceParams) => { const { sourceFilePath, start, end } = params return trimSourceToCache(sourceFilePath, start, end) diff --git a/electron/main/ipc/filesystem.ts b/electron/main/ipc/filesystem.ts index 6a04ab8..1663e5f 100644 --- a/electron/main/ipc/filesystem.ts +++ b/electron/main/ipc/filesystem.ts @@ -1,5 +1,6 @@ import { dialog } from 'electron' import { handle } from './handle' +import { allowLocalPath } from '../services/localFileAccess' export function registerFilesystemHandlers(): void { handle('fs:pickFile', async () => { @@ -7,7 +8,16 @@ export function registerFilesystemHandlers(): void { properties: ['openFile'], filters: [{ name: 'Audio', extensions: ['wav', 'mp3', 'flac', 'aiff', 'ogg', 'm4a'] }], }) - return result.canceled ? null : result.filePaths[0] + if (result.canceled) return null + // The user chose this file, so allow local-file:// to serve it before it lands in any DB row. + allowLocalPath(result.filePaths[0]) + return result.filePaths[0] + }) + + // Broker a path the renderer obtained out-of-band (a drag-dropped file's path from + // webUtils.getPathForFile) so it can be played/analysed before autosave persists the project. + handle('fs:allowPath', (_, filePath: string) => { + allowLocalPath(filePath) }) handle('fs:pickFolder', async () => { diff --git a/electron/main/ipc/freesound.ts b/electron/main/ipc/freesound.ts index a1a1c77..2411259 100644 --- a/electron/main/ipc/freesound.ts +++ b/electron/main/ipc/freesound.ts @@ -5,6 +5,22 @@ import { handle } from './handle' const BASE = 'https://freesound.org/apiv2' +// The download URL comes from the renderer; restrict it to Freesound's own hosts so this handler +// can't be turned into a fetch-any-URL / SSRF primitive that writes arbitrary content into staging +// (F31). Preview/original URLs live on freesound.org and *.freesound.org (e.g. cdn.freesound.org). +function assertFreesoundUrl(raw: string): void { + let url: URL + try { + url = new URL(raw) + } catch { + throw new Error('Invalid download URL') + } + const host = url.hostname + if (url.protocol !== 'https:' || !(host === 'freesound.org' || host.endsWith('.freesound.org'))) { + throw new Error(`Refusing to download from ${url.host}`) + } +} + function getApiKey(): string { try { const s = JSON.parse(fs.readFileSync(path.join(app.getPath('userData'), 'settings.json'), 'utf-8')) @@ -34,6 +50,7 @@ export function registerFreesoundHandlers(): void { }) handle('freesound:download', async (_, _soundId: number, name: string, previewUrl: string) => { + assertFreesoundUrl(previewUrl) const dir = path.join(app.getPath('userData'), 'staging') if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) const outPath = path.join(dir, `${crypto.randomUUID()}.mp3`) diff --git a/electron/main/ipc/library.ts b/electron/main/ipc/library.ts index b94c91f..c0bf015 100644 --- a/electron/main/ipc/library.ts +++ b/electron/main/ipc/library.ts @@ -5,15 +5,17 @@ import * as samples from '../db/queries/samples' const AUDIO_EXTS = new Set(['wav', 'mp3', 'flac', 'aiff', 'aif', 'ogg', 'm4a']) -function scanAudioFiles(dir: string): string[] { - let found: string[] = [] +// Async recursive scan so importing a large/NAS folder doesn't block the event loop (and every +// window + all IPC) the way the old synchronous walk did (F16). +async function scanAudioFiles(dir: string): Promise { let entries: fs.Dirent[] - try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return found } + try { entries = await fs.promises.readdir(dir, { withFileTypes: true }) } catch { return [] } + const found: string[] = [] for (const entry of entries) { if (entry.name.startsWith('.')) continue const full = path.join(dir, entry.name) if (entry.isDirectory()) { - found = found.concat(scanAudioFiles(full)) + found.push(...await scanAudioFiles(full)) } else if (entry.isFile() && AUDIO_EXTS.has(path.extname(entry.name).slice(1).toLowerCase())) { found.push(full) } @@ -21,7 +23,7 @@ function scanAudioFiles(dir: string): string[] { return found } import * as projects from '../db/queries/projects' -import { syncProjectChopsToLibrary, renderLibrarySample } from '../services/materializeChops' +import { syncProjectChopsToLibrary } from '../services/materializeChops' import type { Sample, Project, ProjectRegion } from '../../types' export function registerLibraryHandlers(): void { @@ -37,15 +39,12 @@ export function registerLibraryHandlers(): void { samples.updateSample(id, data) }) - handle('library:importFolder', (_, folderPath: string) => { - const existing = new Set(samples.getAllSamples().map((s) => s.filePath)) - const allFiles = scanAudioFiles(folderPath) - const newFiles = allFiles.filter((f) => !existing.has(f)) - for (const filePath of newFiles) { - const name = path.basename(filePath, path.extname(filePath)) - samples.addSample({ name, filePath, source: 'local' }) - } - return { imported: newFiles.length, skipped: allFiles.length - newFiles.length } + handle('library:importFolder', async (_, folderPath: string) => { + const allFiles = await scanAudioFiles(folderPath) + // Dedup + insert happen in one transaction; INSERT OR IGNORE drops paths already in the library. + const candidates = allFiles.map((filePath) => ({ name: path.basename(filePath, path.extname(filePath)), filePath })) + const imported = samples.importLocalFiles(candidates) + return { imported, skipped: allFiles.length - imported } }) handle('library:deleteSample', (_, id: string) => { @@ -55,35 +54,6 @@ export function registerLibraryHandlers(): void { } }) - handle('library:saveChops', async (_, params: { - sourceFilePath: string - regions: Array<{ start: number; end: number; name: string }> - projectId?: string - }) => { - const saved: Sample[] = [] - - for (const [index, region] of params.regions.entries()) { - const { filePath, duration, waveformData } = await renderLibrarySample( - params.sourceFilePath, - region.start, - region.end - ) - - const sample = samples.addSample({ - name: region.name || `Sample ${index + 1}`, - filePath, - duration, - source: 'local', - projectId: params.projectId ?? null, - waveformData, - }) - - saved.push(sample) - } - - return saved - }) - handle('projects:getAll', () => { return projects.getAllProjects() }) @@ -92,7 +62,7 @@ export function registerLibraryHandlers(): void { return projects.getProject(id) }) - handle('projects:save', async (_, data: { name: string; sourcePath: string | null; sourceName?: string | null; source?: 'local' | 'freesound'; regions: ProjectRegion[] }) => { + handle('projects:save', async (_, data: { name: string; sourcePath: string | null; sourceName?: string | null; source?: 'local' | 'freesound'; freesoundId?: string | null; license?: string | null; author?: string | null; regions: ProjectRegion[] }) => { const project = projects.saveProject(data) await syncProjectChopsToLibrary(project.id) return project diff --git a/electron/main/ipc/packs.ts b/electron/main/ipc/packs.ts index 04d841f..1e3a9e2 100644 --- a/electron/main/ipc/packs.ts +++ b/electron/main/ipc/packs.ts @@ -3,7 +3,9 @@ import path from 'node:path' import fs from 'node:fs' import { handle } from './handle' import * as packsDb from '../db/queries/packs' -import { addSample } from '../db/queries/samples' +import { addSample, getSample, getSampleBySourceChopId as samplesBySourceChopId } from '../db/queries/samples' +import { logMain } from '../services/log' +import type { PackSlot } from '../../types' import { extractWaveformData } from '../audio/waveform' import { getProfile, profiles } from '../hardware/profiles' import type { Pack, PackSourceItem } from '../../types' @@ -26,6 +28,42 @@ function unlinkQuietly(filePath: string | null): void { } } +// Write a credits.txt next to the exported audio listing attribution for any Creative Commons +// sources in the pack, so the user can comply with Freesound licenses when sharing the pack (F24). +// Resolves each pad to its sample (library-sample pads by id, chop pads by source chop id) and +// dedupes by Freesound id. No-op when nothing needs crediting. +function writeCreditsFile(outputDir: string, packName: string, slots: PackSlot[]): void { + const seen = new Set() + const lines: string[] = [] + for (const slot of slots) { + const sample = slot.sampleId + ? getSample(slot.sampleId) + : slot.projectChopId + ? samplesBySourceChopId(slot.projectChopId) + : null + if (!sample || (!sample.license && !sample.author)) continue + const key = sample.freesoundId ?? sample.id + if (seen.has(key)) continue + seen.add(key) + const parts = [sample.name] + if (sample.author) parts.push(`by ${sample.author}`) + if (sample.license) parts.push(`(${sample.license})`) + if (sample.freesoundId) parts.push(`— https://freesound.org/s/${sample.freesoundId}/`) + lines.push(`- ${parts.join(' ')}`) + } + if (lines.length === 0) return + + const body = + `Credits for "${packName}"\n\n` + + `This pack contains Creative Commons audio from Freesound. Most CC licenses require attribution — please keep these credits with the pack:\n\n` + + `${lines.join('\n')}\n` + try { + fs.writeFileSync(path.join(outputDir, 'credits.txt'), body) + } catch (error) { + logMain('writeCreditsFile:failed', error) + } +} + export function registerPacksHandlers(): void { handle('packs:getAll', () => { return packsDb.getAllPacks() @@ -86,7 +124,9 @@ export function registerPacksHandlers(): void { end: slot.audioPath ? null : slot.end, })) - return exportClips(profile, clips, outputDir) + const result = await exportClips(profile, clips, outputDir) + writeCreditsFile(outputDir, pack.name, pack.slots) + return result }) // Recover an orphaned chop pad (its origin chop was deleted, so its library sample is gone) by @@ -106,6 +146,7 @@ export function registerPacksHandlers(): void { filePath, source: 'local', waveformData: extractWaveformData(filePath), + owned: true, }) packsDb.relinkSlotToSample(packId, slotNumber, sample) return sample diff --git a/electron/main/ipc/settings.ts b/electron/main/ipc/settings.ts index 80e69f2..32f5bc9 100644 --- a/electron/main/ipc/settings.ts +++ b/electron/main/ipc/settings.ts @@ -2,21 +2,32 @@ import { app } from 'electron' import path from 'node:path' import fs from 'node:fs' import { handle } from './handle' +import { logMain } from '../services/log' function settingsPath() { return path.join(app.getPath('userData'), 'settings.json') } function read(): Record { + const file = settingsPath() + if (!fs.existsSync(file)) return {} try { - return JSON.parse(fs.readFileSync(settingsPath(), 'utf-8')) - } catch { + return JSON.parse(fs.readFileSync(file, 'utf-8')) + } catch (error) { + // The file exists but didn't parse (e.g. truncated by a crash mid-write). Log it rather than + // silently returning {} and losing the Freesound key with no trace (F29). + logMain('settings:read-corrupt', error) return {} } } +// Write atomically: a full write to a temp file then rename, so a crash mid-write can't truncate +// settings.json and make read() silently fall back to {} (F30). function write(data: Record): void { - fs.writeFileSync(settingsPath(), JSON.stringify(data, null, 2)) + const file = settingsPath() + const tmp = `${file}.${process.pid}.tmp` + fs.writeFileSync(tmp, JSON.stringify(data, null, 2)) + fs.renameSync(tmp, file) } export function registerSettingsHandlers(): void { diff --git a/electron/main/services/export.ts b/electron/main/services/export.ts index 8ba5e29..3e09e0d 100644 --- a/electron/main/services/export.ts +++ b/electron/main/services/export.ts @@ -2,6 +2,7 @@ import path from 'node:path' import fs from 'node:fs' import { type HardwareProfile } from '../hardware/profiles' import { renderClip } from './render' +import { logMain } from './log' export type ExportClip = { sourcePath: string @@ -14,23 +15,66 @@ export type ExportClip = { end: number | null } +// Cap concurrent ffmpeg spawns so a full pack export doesn't launch one process per pad at once +// (F27). Runs each item through the worker with at most `limit` in flight, preserving order. +async function mapLimit(items: T[], limit: number, worker: (item: T, index: number) => Promise): Promise { + const results: R[] = new Array(items.length) + let next = 0 + const runner = async () => { + while (next < items.length) { + const index = next++ + results[index] = await worker(items[index], index) + } + } + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, runner)) + return results +} + +// Resolve a collision-free filename. Two pads whose display names sanitize to the same string, or an +// empty display name, would otherwise render to the same path in parallel — a silent overwrite +// reported as success (F6). Collisions get a numeric suffix; empty names fall back to the slot. +function uniqueFileName(profile: HardwareProfile, clip: ExportClip, used: Set): string { + const fileName = profile.fileName(clip.slotNumber, clip.name) + const ext = path.extname(fileName) + let base = ext ? fileName.slice(0, -ext.length) : fileName + if (!base) base = `pad_${clip.slotNumber + 1}` + + let candidate = `${base}${ext}` + let n = 2 + while (used.has(candidate.toLowerCase())) { + candidate = `${base}_${n}${ext}` + n++ + } + used.add(candidate.toLowerCase()) + return candidate +} + +const EXPORT_CONCURRENCY = 4 + // The single place that knows how a chop becomes a hardware-ready file: it renders each clip to -// outputDir using the profile's container/sample-rate/sample-format and filename convention, runs -// them in parallel, and reports how many were written. The region-export and pack-export IPC -// handlers are thin adapters that build the clip list; the rendering rules live only here. +// outputDir using the profile's container/sample-rate/sample-format and filename convention, and +// reports how many were actually written. Renders are capped-concurrency and independent — one +// failing clip no longer aborts its siblings (F7); the caller surfaces the written/failed counts. export async function exportClips( profile: HardwareProfile, clips: ExportClip[], outputDir: string -): Promise<{ filesWritten: number }> { +): Promise<{ filesWritten: number; failed: number }> { if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true }) - await Promise.all( - clips.map((clip) => { - const outputFile = path.join(outputDir, profile.fileName(clip.slotNumber, clip.name)) - return renderClip(clip.sourcePath, { start: clip.start, end: clip.end }, outputFile, profile.format) - }) - ) + const used = new Set() + const targets = clips.map((clip) => ({ clip, outputFile: path.join(outputDir, uniqueFileName(profile, clip, used)) })) + + const outcomes = await mapLimit(targets, EXPORT_CONCURRENCY, async ({ clip, outputFile }) => { + try { + await renderClip(clip.sourcePath, { start: clip.start, end: clip.end }, outputFile, profile.format) + return true + } catch (error) { + logMain('exportClip:failed', error) + return false + } + }) - return { filesWritten: clips.length } + const filesWritten = outcomes.filter(Boolean).length + return { filesWritten, failed: outcomes.length - filesWritten } } diff --git a/electron/main/services/ffmpeg.ts b/electron/main/services/ffmpeg.ts index 7dfe9cc..1ec3ec1 100644 --- a/electron/main/services/ffmpeg.ts +++ b/electron/main/services/ffmpeg.ts @@ -1,5 +1,6 @@ import { app } from 'electron' import path from 'node:path' +import fs from 'node:fs' import { createRequire } from 'node:module' import ffmpeg from 'fluent-ffmpeg' @@ -44,4 +45,16 @@ export function configureFfmpeg(): void { ffmpeg.setFfmpegPath(ffmpegPath) } +// Whether the bundled ffmpeg binary actually exists for this platform/arch. @ffmpeg-installer only +// pins darwin/win32 in optionalDependencies, so an unsupported arch (e.g. Linux) resolves nothing +// and every render would fail deep in a per-clip rejection. Probing once at startup lets us tell the +// user plainly instead of surfacing it as mysterious per-operation failures (F17). +export function isFfmpegAvailable(): boolean { + try { + return fs.existsSync(getFfmpegBinaryPath()) + } catch { + return false + } +} + export { ffmpeg } diff --git a/electron/main/services/gc.ts b/electron/main/services/gc.ts new file mode 100644 index 0000000..f8107fc --- /dev/null +++ b/electron/main/services/gc.ts @@ -0,0 +1,54 @@ +import { app } from 'electron' +import path from 'node:path' +import fs from 'node:fs' +import { getDb } from '../db/index' + +// App-owned directories whose files are always referenced by exactly one DB row. `stems/` is +// deliberately excluded: it is a content-addressed cache keyed by source hash, referenced only by +// its presence on disk (see services/stems getCachedStems), not by any row. +const MANAGED_DIRS = ['samples', 'sources', 'staging', 'pack-slots'] + +// Every file path the database still points at: rendered library samples, pad-owned pack audio, and +// project source files (trimmed sources under sources/, Freesound previews under staging/). +function referencedPaths(): Set { + const db = getDb() + const paths = new Set() + for (const r of db.prepare('SELECT file_path FROM samples').all() as { file_path: string }[]) paths.add(r.file_path) + for (const r of db.prepare('SELECT audio_path FROM pack_slots WHERE audio_path IS NOT NULL').all() as { audio_path: string }[]) paths.add(r.audio_path) + for (const r of db.prepare('SELECT source_path FROM projects WHERE source_path IS NOT NULL').all() as { source_path: string }[]) paths.add(r.source_path) + return paths +} + +// Sweep the app-owned directories for files no DB row references and unlink them. Covers the leaks +// the audit calls out: orphaned pad WAVs from racing/crashing upsertSlot (F15), re-trimmed sources, +// deleted-project staging files, and any render left behind by a crash between write and DB commit +// (F21). Conservative by construction — it only ever touches files under MANAGED_DIRS, never a +// user's own imported files (which live outside userData) or the stem cache. +export function gcOrphanedFiles(): { removed: number } { + const referenced = referencedPaths() + const userData = app.getPath('userData') + let removed = 0 + + for (const dirName of MANAGED_DIRS) { + const dir = path.join(userData, dirName) + let entries: fs.Dirent[] + try { + entries = fs.readdirSync(dir, { withFileTypes: true }) + } catch { + continue // dir doesn't exist yet + } + for (const entry of entries) { + if (!entry.isFile()) continue + const full = path.join(dir, entry.name) + if (referenced.has(full)) continue + try { + fs.unlinkSync(full) + removed++ + } catch { + // Locked/already gone — leave it for the next sweep. + } + } + } + + return { removed } +} diff --git a/electron/main/services/localFileAccess.ts b/electron/main/services/localFileAccess.ts new file mode 100644 index 0000000..98b9f8c --- /dev/null +++ b/electron/main/services/localFileAccess.ts @@ -0,0 +1,47 @@ +import { app } from 'electron' +import path from 'node:path' +import { getDb } from '../db/index' + +// Gate for the `local-file://` protocol. Without it any renderer script could `fetch` an arbitrary +// path — the protocol is CORS-open and bypasses CSP — turning a renderer bug into a full-disk read +// (F32). A path is served only when the app has a legitimate reason to expose it: +// 1. it lives under userData (rendered samples, trimmed sources, staging, stems, pack audio), or +// 2. a DB row references it (imported-in-place library files, project sources, pad audio), or +// 3. main explicitly brokered it this session (a file the user just picked or dropped, before it +// has been persisted to any row). +const brokered = new Set() + +// Record a path the renderer legitimately obtained through main (file picker) or told us it just +// loaded (drag-drop). Cheap and idempotent. +export function allowLocalPath(filePath: string | null | undefined): void { + if (filePath) brokered.add(path.resolve(filePath)) +} + +function underUserData(resolved: string): boolean { + const base = path.resolve(app.getPath('userData')) + path.sep + return resolved === path.resolve(app.getPath('userData')) || resolved.startsWith(base) +} + +function knownToDb(candidate: string): boolean { + try { + return !!getDb() + .prepare(` + SELECT 1 FROM samples WHERE file_path = ? + UNION SELECT 1 FROM projects WHERE source_path = ? + UNION SELECT 1 FROM pack_slots WHERE audio_path = ? + LIMIT 1 + `) + .get(candidate, candidate, candidate) + } catch { + return false + } +} + +// True when the protocol handler is allowed to serve `filePath`. Resolving first collapses any +// `..` traversal so an escape attempt falls through to the DB/brokered checks and is denied. +export function isLocalFileAllowed(filePath: string): boolean { + const resolved = path.resolve(filePath) + if (underUserData(resolved)) return true + if (brokered.has(resolved)) return true + return knownToDb(filePath) || knownToDb(resolved) +} diff --git a/electron/main/services/log.ts b/electron/main/services/log.ts new file mode 100644 index 0000000..74dcdc5 --- /dev/null +++ b/electron/main/services/log.ts @@ -0,0 +1,33 @@ +import { app } from 'electron' +import path from 'node:path' +import fs from 'node:fs' + +// Lightweight file logger shared by main-process services so swallowed errors leave a trail (F29). +// Writes to the same app log directory as the fatal-error path in main/index.ts; a user reporting +// "my library lost samples" then has something to inspect instead of a silent `catch {}`. +function logFilePath(): string { + try { + return path.join(app.getPath('logs'), 'main.log') + } catch { + return path.join(app.getPath('temp'), 'samplebyte-main.log') + } +} + +export function logMain(message: string, data?: unknown): void { + let detail = '' + if (data !== undefined) { + if (data instanceof Error) detail = ` ${data.name}: ${data.message}` + else if (typeof data === 'string') detail = ` ${data}` + else { + try { detail = ` ${JSON.stringify(data)}` } catch { detail = ` ${String(data)}` } + } + } + const line = `[${new Date().toISOString()}] ${message}${detail}\n` + try { + const p = logFilePath() + fs.mkdirSync(path.dirname(p), { recursive: true }) + fs.appendFileSync(p, line) + } catch { + console.error(line) + } +} diff --git a/electron/main/services/materializeChops.ts b/electron/main/services/materializeChops.ts index af91df1..f163fcd 100644 --- a/electron/main/services/materializeChops.ts +++ b/electron/main/services/materializeChops.ts @@ -3,7 +3,7 @@ import path from 'node:path' import fs from 'node:fs' import { renderClip, LIBRARY_FORMAT } from './render' import { isChopSampleStale } from './sourceChange' -import { extractWaveformData } from '../audio/waveform' +import { extractWaveformData, readWavDuration } from '../audio/waveform' import { addSample, getAllSamples, @@ -11,7 +11,8 @@ import { refreshChopSample, deleteChopSampleRow, } from '../db/queries/samples' -import { getAllProjectChops, getProject, getProjectChops } from '../db/queries/projects' +import { getAllProjectChops, getProject, getProjectChops, markChopMaterializeFailed } from '../db/queries/projects' +import { logMain } from './log' type ChopLike = { id: string; projectId: string; name: string; start: number; end: number } @@ -31,11 +32,17 @@ export async function renderLibrarySample( ): Promise<{ filePath: string; duration: number; waveformData: number[] }> { const filePath = path.join(ensureSamplesDir(), `${crypto.randomUUID()}.wav`) await renderClip(sourcePath, { start, end }, filePath, LIBRARY_FORMAT) - return { filePath, duration: end - start, waveformData: extractWaveformData(filePath) } + // Record the duration actually written, not the requested span: a chop dragged past the decoded + // end yields a shorter file, and mp3 duration jitter can shift the tail (F9). + const duration = readWavDuration(filePath) ?? end - start + return { filePath, duration, waveformData: extractWaveformData(filePath) } } -// Trim one chop to a real WAV and insert it as a library sample (source 'chop') with provenance. -async function materializeChop(chop: ChopLike, sourcePath: string): Promise { +type Attribution = { freesoundId: string | null; license: string | null; author: string | null } + +// Trim one chop to a real WAV and insert it as a library sample (source 'chop') with provenance and, +// when the source is a Freesound download, its attribution so exported packs can credit it (F24). +async function materializeChop(chop: ChopLike, sourcePath: string, attribution?: Attribution): Promise { const { filePath, duration, waveformData } = await renderLibrarySample(sourcePath, chop.start, chop.end) addSample({ name: chop.name, @@ -44,7 +51,11 @@ async function materializeChop(chop: ChopLike, sourcePath: string): Promise { const materialized = getMaterializedChopIds() - const pending = getAllProjectChops().filter((chop) => chop.sourcePath && !materialized.has(chop.id)) + const pending = getAllProjectChops().filter( + (chop) => chop.sourcePath && !materialized.has(chop.id) && !chop.materializeFailed + ) if (pending.length === 0) return 0 let count = 0 for (const chop of pending) { try { - await materializeChop(chop, chop.sourcePath!) + await materializeChop(chop, chop.sourcePath!, { freesoundId: chop.freesoundId, license: chop.license, author: chop.author }) count++ } catch { - // Source missing/unreadable — skip, leave un-materialized so a later run retries. + // Source missing/unreadable — record the failure so this doomed render isn't retried on every + // launch forever (F14). + markChopMaterializeFailed(chop.id) } } return count } +// Serialize syncs per project. Multiple renderer paths (autosave upsert, send-to-pack, trim) can +// trigger a sync for the same project while an earlier one is still awaiting ffmpeg; overlapping +// runs both saw "no sample for chop X" and both inserted, duplicating rows and WAVs (F4). Chaining +// per projectId guarantees at most one sync runs for a project at a time. +const syncQueues = new Map>() + +export function syncProjectChopsToLibrary(projectId: string): Promise { + const prev = syncQueues.get(projectId) ?? Promise.resolve() + const next = prev.then(() => doSyncProjectChopsToLibrary(projectId), () => doSyncProjectChopsToLibrary(projectId)) + syncQueues.set(projectId, next) + next.finally(() => { if (syncQueues.get(projectId) === next) syncQueues.delete(projectId) }) + return next +} + // Reconcile one project's materialized chop samples with its current chops, so the library stays a // live projection of the project. Incremental: new chops are trimmed, chops edited since their // sample was built are re-trimmed (in place, keeping the sample id), and removed chops drop from // the library. Pack slots are left untouched — packs are independent snapshots. -export async function syncProjectChopsToLibrary(projectId: string): Promise { +async function doSyncProjectChopsToLibrary(projectId: string): Promise { const project = getProject(projectId) if (!project?.sourcePath) return const sourcePath = project.sourcePath + const attribution: Attribution = { freesoundId: project.freesoundId, license: project.license, author: project.author } const chops = getProjectChops(projectId) const existing = getAllSamples({ projectId }).filter((s) => s.source === 'chop') const existingByChopId = new Map(existing.map((s) => [s.sourceChopId, s])) @@ -86,7 +116,7 @@ export async function syncProjectChopsToLibrary(projectId: string): Promise { expect(readWavInfo(out).sampleRate).toBe(48000) }) + it('renders 24-bit PCM (s24) via the pcm_s24le codec', async () => { + // Regression for F2: `-sample_fmt s24` is not a valid ffmpeg option, so 24-bit profiles used to + // fail every render. renderClip must map s24 -> pcm_s24le and produce a real 24-bit WAV. + const out = path.join(tmpDir, '24bit.wav') + await renderClip(FIXTURE, { start: 0, end: 0.5 }, out, { + container: 'wav', + sampleRate: 44100, + sampleFmt: 's24', + }) + + const info = readWavInfo(out) + // 24-bit PCM is written as WAVE_FORMAT_EXTENSIBLE (0xFFFE), not plain PCM (1); both are PCM. + expect([1, 0xfffe]).toContain(info.audioFormat) + expect(info.bitsPerSample).toBe(24) + }) + + it('rejects an invalid trim window before spawning ffmpeg', async () => { + const out = path.join(tmpDir, 'bad.wav') + await expect( + renderClip(FIXTURE, { start: 1, end: 0.5 }, out, LIBRARY_FORMAT) + ).rejects.toThrow(/Invalid trim window/) + await expect( + renderClip(FIXTURE, { start: 0, end: Number.NaN }, out, LIBRARY_FORMAT) + ).rejects.toThrow(/Invalid trim window/) + }) + it('rejects when the source does not exist', async () => { const out = path.join(tmpDir, 'never.wav') await expect( diff --git a/electron/main/services/render.ts b/electron/main/services/render.ts index 62cdd1c..ecfa96f 100644 --- a/electron/main/services/render.ts +++ b/electron/main/services/render.ts @@ -16,13 +16,27 @@ export type RenderFormat = { // export renders at the per-profile format instead (see HardwareProfile.format). export const LIBRARY_FORMAT: RenderFormat = { container: 'wav', sampleRate: 44100, sampleFmt: 's16' } +// Map the profile's PCM sample-format tag to a real ffmpeg encoder. `-sample_fmt s24` is NOT a valid +// ffmpeg option (there is no s24 packed sample format), which silently broke every 24-bit profile +// render; the correct 24-bit path is the pcm_s24le codec (F2). Selecting the codec also removes any +// dependence on ffmpeg's default codec per container. +const PCM_CODECS: Record = { + s16: 'pcm_s16le', + s24: 'pcm_s24le', + s32: 'pcm_s32le', +} + +// Hard ceiling per clip so a corrupt input that makes ffmpeg hang can't leave a promise pending +// forever (F27). Generous enough for a long full-source conversion. +const RENDER_TIMEOUT_MS = 120_000 + // Trim window into the source. Both null means render the whole source (a format conversion). export type RenderRegion = { start: number | null; end: number | null } // The one place that turns a source region into a real audio file. Every audio output in the app — // library samples, pad-owned pack audio, and hardware export — renders through here, so the ffmpeg -// flags and the trim-window rule live in a single function. Output is always stereo at the given -// format's sample rate and sample format. +// flags, the trim-window rule, and region validation live in a single function. Output is always +// stereo at the given format's sample rate and sample format. export function renderClip( sourcePath: string, region: RenderRegion, @@ -30,7 +44,29 @@ export function renderClip( format: RenderFormat ): Promise { return new Promise((resolve, reject) => { + // Validate the trim window here — the single funnel every render path flows through — so bad + // bounds (NaN/Infinity/negative, end<=start) fail fast with a clear message instead of reaching + // ffmpeg as an unparseable duration or being swallowed downstream (F28). + if (region.start !== null || region.end !== null) { + const { start, end } = region + if (start === null || end === null || !Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end <= start) { + reject(new Error(`Invalid trim window: start=${start}, end=${end}`)) + return + } + } + + const codec = PCM_CODECS[format.sampleFmt] ?? PCM_CODECS.s16 const cmd = ffmpeg(sourcePath) + let settled = false + let timer: ReturnType | null = null + const finish = (err?: Error) => { + if (settled) return + settled = true + if (timer) clearTimeout(timer) + if (err) reject(err) + else resolve() + } + if (region.start !== null && region.end !== null) { cmd.setStartTime(region.start).setDuration(region.end - region.start) } @@ -38,10 +74,15 @@ export function renderClip( .toFormat(format.container) .audioFrequency(format.sampleRate) .audioChannels(2) - .outputOptions([`-sample_fmt ${format.sampleFmt}`]) + .audioCodec(codec) .output(outputPath) - .on('end', () => resolve()) - .on('error', reject) + .on('end', () => finish()) + .on('error', (err) => finish(err instanceof Error ? err : new Error(String(err)))) .run() + + timer = setTimeout(() => { + try { cmd.kill('SIGKILL') } catch { /* already exited */ } + finish(new Error(`ffmpeg render timed out after ${RENDER_TIMEOUT_MS}ms: ${sourcePath}`)) + }, RENDER_TIMEOUT_MS) }) } diff --git a/electron/main/services/stems.test.ts b/electron/main/services/stems.test.ts index 3e347e2..7cf52b1 100644 --- a/electron/main/services/stems.test.ts +++ b/electron/main/services/stems.test.ts @@ -19,9 +19,14 @@ function makeStems(): StemPcm[] { }) } +// Valid SHA-256-shaped hashes (64 lowercase hex chars); the service rejects anything else (F31). +const HASH_A = 'a'.repeat(64) +const HASH_B = 'b'.repeat(64) +const HASH_C = 'c'.repeat(64) + describe('stems service', () => { it('persists each stem as a normalized LIBRARY_FORMAT WAV', async () => { - const files = await persistStems('hash-persist', makeStems()) + const files = await persistStems(HASH_A, makeStems()) expect(files.map((f) => f.name).sort()).toEqual([...STEM_NAMES].sort()) for (const f of files) { expect(fs.existsSync(f.filePath)).toBe(true) @@ -35,11 +40,17 @@ describe('stems service', () => { }) it('returns the full cached set after persisting, and null otherwise', async () => { - await persistStems('hash-cache', makeStems()) - const cached = getCachedStems('hash-cache') + await persistStems(HASH_B, makeStems()) + const cached = getCachedStems(HASH_B) expect(cached).not.toBeNull() expect(cached!.map((f) => f.name).sort()).toEqual([...STEM_NAMES].sort()) - expect(getCachedStems('never-separated')).toBeNull() + expect(getCachedStems(HASH_C)).toBeNull() + }) + + it('rejects a source hash that could escape the stems directory (F31)', async () => { + expect(() => getCachedStems('../../etc')).toThrow(/invalid stem source hash/) + await expect(persistStems('../../../etc/passwd', makeStems())).rejects.toThrow(/invalid stem source hash/) + expect(() => getCachedStems('NOTHEX'.padEnd(64, 'g'))).toThrow(/invalid stem source hash/) }) it('rejects unknown model file names', () => { diff --git a/electron/main/services/stems.ts b/electron/main/services/stems.ts index 3e64a74..9844367 100644 --- a/electron/main/services/stems.ts +++ b/electron/main/services/stems.ts @@ -21,7 +21,15 @@ export function getModelFile(name: string): Uint8Array { return fs.readFileSync(full) } +// The sourceHash is a renderer-supplied string used as a directory name. Constrain it to a real +// SHA-256 hex digest so it can't contain path separators or `..` and escape userData/stems to read +// or clobber arbitrary files (F31). +function assertValidHash(sourceHash: string): void { + if (!/^[0-9a-f]{64}$/.test(sourceHash)) throw new Error('invalid stem source hash') +} + function stemsDir(sourceHash: string): string { + assertValidHash(sourceHash) return path.join(app.getPath('userData'), 'stems', sourceHash) } diff --git a/electron/main/update.ts b/electron/main/update.ts index f69bcd1..fb58f95 100644 --- a/electron/main/update.ts +++ b/electron/main/update.ts @@ -1,76 +1,58 @@ -import { app, ipcMain } from 'electron' +import { app } from 'electron' import { createRequire } from 'node:module' -import type { - ProgressInfo, - UpdateDownloadedEvent, - UpdateInfo, -} from 'electron-updater' - -const { autoUpdater } = createRequire(import.meta.url)('electron-updater'); - -export function update(win: Electron.BrowserWindow) { - - // When set to false, the update download will be triggered through the API +import type { ProgressInfo, UpdateInfo } from 'electron-updater' +import { handle } from './ipc/handle' +import { logMain } from './services/log' + +const { autoUpdater } = createRequire(import.meta.url)('electron-updater') + +export type UpdateCheckResult = + | { available: boolean; version: string; newVersion?: string } + | { error: string } + +// Wire electron-updater to the renderer. Previously the module registered handlers and sent +// `update-can-available`, but nothing was exposed through the preload bridge, so with context +// isolation the renderer could neither invoke nor receive any of it — the updater was dead code and +// users never got an update prompt (F18). Now the check/download/install verbs and the progress +// events are part of the typed contract and consumed by the UpdateBanner. +export function registerUpdateHandlers(win: Electron.BrowserWindow): void { autoUpdater.autoDownload = false - autoUpdater.disableWebInstaller = false + autoUpdater.autoInstallOnAppQuit = true autoUpdater.allowDowngrade = false - // start check - autoUpdater.on('checking-for-update', function () { }) - // update available - autoUpdater.on('update-available', (arg: UpdateInfo) => { - win.webContents.send('update-can-available', { update: true, version: app.getVersion(), newVersion: arg?.version }) + autoUpdater.on('update-available', (info: UpdateInfo) => { + win.webContents.send('update:available', { version: app.getVersion(), newVersion: info?.version }) }) - // update not available - autoUpdater.on('update-not-available', (arg: UpdateInfo) => { - win.webContents.send('update-can-available', { update: false, version: app.getVersion(), newVersion: arg?.version }) + autoUpdater.on('download-progress', (info: ProgressInfo) => { + win.webContents.send('update:progress', info?.percent ?? 0) + }) + autoUpdater.on('update-downloaded', () => { + win.webContents.send('update:downloaded') + }) + autoUpdater.on('error', (error: Error) => { + logMain('autoUpdater:error', error) + win.webContents.send('update:error', error?.message ?? 'Update failed') }) - // Checking for updates - ipcMain.handle('check-update', async () => { - if (!app.isPackaged) { - const error = new Error('The update feature is only available after the package.') - return { message: error.message, error } - } - + handle('updates:check', async (): Promise => { + // Updates only exist for a packaged, published build; in dev there is nothing to check. + if (!app.isPackaged) return { available: false, version: app.getVersion() } try { - return await autoUpdater.checkForUpdatesAndNotify() + const result = await autoUpdater.checkForUpdates() + const newVersion = result?.updateInfo?.version + return { available: !!newVersion && newVersion !== app.getVersion(), version: app.getVersion(), newVersion } } catch (error) { - return { message: 'Network error', error } + logMain('updates:check-failed', error) + return { error: error instanceof Error ? error.message : 'Update check failed' } } }) - // Start downloading and feedback on progress - ipcMain.handle('start-download', (event: Electron.IpcMainInvokeEvent) => { - startDownload( - (error, progressInfo) => { - if (error) { - // feedback download error message - event.sender.send('update-error', { message: error.message, error }) - } else { - // feedback update progress message - event.sender.send('download-progress', progressInfo) - } - }, - () => { - // feedback update downloaded message - event.sender.send('update-downloaded') - } - ) + handle('updates:download', async () => { + await autoUpdater.downloadUpdate() }) - // Install now - ipcMain.handle('quit-and-install', () => { + handle('updates:install', async () => { + // isSilent=false, isForceRunAfter=true: quit, install, relaunch. autoUpdater.quitAndInstall(false, true) }) } - -function startDownload( - callback: (error: Error | null, info: ProgressInfo | null) => void, - complete: (event: UpdateDownloadedEvent) => void, -) { - autoUpdater.on('download-progress', (info: ProgressInfo) => callback(null, info)) - autoUpdater.on('error', (error: Error) => callback(error, null)) - autoUpdater.on('update-downloaded', complete) - autoUpdater.downloadUpdate() -} diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 089ade7..ac9f12f 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -1,15 +1,22 @@ import { ipcRenderer, contextBridge, webUtils } from 'electron' -import type { Api } from '../ipc-contract' +import type { Api, ApiEvents } from '../ipc-contract' + +// Subscribe to a main->renderer channel, returning an unsubscribe. Args are forwarded straight to +// the callback; the sender event object is stripped so the renderer never touches ipc internals. +function subscribe(channel: string, cb: (...args: unknown[]) => void): () => void { + const listener = (_e: Electron.IpcRendererEvent, ...args: unknown[]) => cb(...args) + ipcRenderer.on(channel, listener) + return () => ipcRenderer.removeListener(channel, listener) +} // Typed against the shared contract: each method just forwards to its `group:method` channel, and // the contract guarantees the args/return match what main registers. Drift is a compile error. -const api: Api = { +const api: Api & { events: ApiEvents } = { library: { getSamples: (filters) => ipcRenderer.invoke('library:getSamples', filters), addSample: (data) => ipcRenderer.invoke('library:addSample', data), updateSample: (id, data) => ipcRenderer.invoke('library:updateSample', id, data), deleteSample: (id) => ipcRenderer.invoke('library:deleteSample', id), - saveChops: (params) => ipcRenderer.invoke('library:saveChops', params), importFolder: (folderPath) => ipcRenderer.invoke('library:importFolder', folderPath), getPackSlotRefCount: (id) => ipcRenderer.invoke('library:getPackSlotRefCount', id), getOrphans: () => ipcRenderer.invoke('library:getOrphans'), @@ -29,7 +36,6 @@ const api: Api = { }, audio: { - exportRegions: (params) => ipcRenderer.invoke('audio:exportRegions', params), trimSource: (params) => ipcRenderer.invoke('audio:trimSource', params), }, @@ -41,6 +47,7 @@ const api: Api = { fs: { getPathForFile: (file) => webUtils.getPathForFile(file), + allowPath: (filePath) => ipcRenderer.invoke('fs:allowPath', filePath), pickFile: () => ipcRenderer.invoke('fs:pickFile'), pickFolder: () => ipcRenderer.invoke('fs:pickFolder'), }, @@ -71,6 +78,20 @@ const api: Api = { export: (packId, outputDir) => ipcRenderer.invoke('packs:export', packId, outputDir), regenerateSlotToLibrary: (packId, slotNumber) => ipcRenderer.invoke('packs:regenerateSlotToLibrary', packId, slotNumber), }, + + updates: { + check: () => ipcRenderer.invoke('updates:check'), + download: () => ipcRenderer.invoke('updates:download'), + install: () => ipcRenderer.invoke('updates:install'), + }, + + events: { + onLibraryChanged: (cb) => subscribe('library:changed', () => cb()), + onUpdateAvailable: (cb) => subscribe('update:available', (info) => cb(info as { version: string; newVersion?: string })), + onUpdateProgress: (cb) => subscribe('update:progress', (percent) => cb(percent as number)), + onUpdateDownloaded: (cb) => subscribe('update:downloaded', () => cb()), + onUpdateError: (cb) => subscribe('update:error', (message) => cb(message as string)), + }, } contextBridge.exposeInMainWorld('api', api) diff --git a/electron/types.ts b/electron/types.ts index a702503..397328c 100644 --- a/electron/types.ts +++ b/electron/types.ts @@ -8,11 +8,19 @@ export type Sample = { tags: string[] source: 'local' | 'freesound' | 'chop' freesoundId: string | null + // Attribution for Creative Commons audio from Freesound, carried so exported packs can credit + // sources (most CC licenses require it). Null for local/own audio. See F24. + license: string | null + author: string | null waveformData: number[] | null projectId: string | null // Set when this sample was materialized from a project chop (trimmed to a real file). // Doubles as the idempotency key for the one-time chop materialization migration. sourceChopId: string | null + // True only for files the app rendered/copied into its own storage (userData/samples). Deleting + // such a row deletes its file; import-in-place originals and shared cache files (stems) are + // owned=false and are never unlinked. See F1/T1 in the adversarial audit. + owned: boolean createdAt: number } @@ -35,8 +43,6 @@ export type PackSlot = { end: number | null displayName: string sourceChopUpdatedAt: number | null - pitchShiftSemitones: number | null - timeStretchRatio: number | null // Owned trimmed WAV captured at assignment, so the pad exports independently of its source. audioPath: string | null } @@ -47,10 +53,23 @@ export type Project = { sourcePath: string | null sourceName: string | null source: 'local' | 'freesound' + // Freesound attribution for the source, propagated to materialized chop samples so packs can be + // credited on export (F24). + freesoundId: string | null + license: string | null + author: string | null regions: ProjectRegion[] createdAt: number } +// Freesound attribution captured when a preview is opened, carried through project save into +// materialized samples. +export type FreesoundAttribution = { + freesoundId: string + license: string + author: string +} + export type ProjectRegion = { id?: string start: number @@ -86,13 +105,6 @@ export type PackSourceItem = { sourceChopUpdatedAt: number | null } -export type ExportRegionsParams = { - regions: ProjectRegion[] - sourceFilePath: string - outputDir: string - profileId: string -} - export type TrimSourceParams = { sourceFilePath: string start: number diff --git a/scripts/seed.sh b/scripts/seed.sh index 0f422a6..c8a42a7 100755 --- a/scripts/seed.sh +++ b/scripts/seed.sh @@ -8,11 +8,19 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SEED_AUDIO_DIR="$SCRIPT_DIR/seed-audio" # ── 1. Locate userData ────────────────────────────────────────────────────── -if [ -d "$HOME/Library/Application Support/samplebyte" ]; then - USERDATA="$HOME/Library/Application Support/samplebyte" -elif [ -d "$HOME/Library/Application Support/Electron" ]; then - USERDATA="$HOME/Library/Application Support/Electron" -else +# Electron's userData path is per-platform: macOS uses ~/Library/Application Support, Linux uses +# $XDG_CONFIG_HOME (~/.config), Windows uses %APPDATA% (reachable from git-bash/WSL). Try each. +CANDIDATES=( + "$HOME/Library/Application Support/samplebyte" + "$HOME/Library/Application Support/Electron" + "${XDG_CONFIG_HOME:-$HOME/.config}/samplebyte" + "${APPDATA:-$HOME/AppData/Roaming}/samplebyte" +) +USERDATA="" +for candidate in "${CANDIDATES[@]}"; do + if [ -d "$candidate" ]; then USERDATA="$candidate"; break; fi +done +if [ -z "$USERDATA" ]; then echo "Error: userData directory not found." >&2 echo "Run 'pnpm dev' at least once first." >&2 exit 1 @@ -91,15 +99,19 @@ sqlite3 "$DB" " ); " -# ── 4. Wipe existing data and insert seed ─────────────────────────────────── -echo "Clearing existing data..." +# ── 4. Clear only previously-seeded data, then insert seed ────────────────── +# Non-destructive: remove only rows this script created (id prefix 'seed-'/'seed-proj-'/etc.) plus +# the library samples materialized from seed chops, so re-seeding doesn't wipe the developer's own +# projects, packs, and imported samples. Orphaned WAVs left behind are reclaimed by the app's +# startup GC sweep. (F36) +echo "Clearing previously-seeded data..." sqlite3 "$DB" " PRAGMA foreign_keys = OFF; - DELETE FROM pack_slots; - DELETE FROM packs; - DELETE FROM project_chops; - DELETE FROM projects; - DELETE FROM samples; + DELETE FROM pack_slots WHERE pack_id LIKE 'seed-%'; + DELETE FROM packs WHERE id LIKE 'seed-%'; + DELETE FROM samples WHERE source_chop_id LIKE 'seed-%'; + DELETE FROM project_chops WHERE id LIKE 'seed-%'; + DELETE FROM projects WHERE id LIKE 'seed-%'; PRAGMA foreign_keys = ON; " @@ -153,35 +165,35 @@ sqlite3 "$DB" " VALUES ('seed-pack-hiphop-0000000001', 'Hip Hop Kit Vol.1', 'sp404-mkii', 1735862400000); INSERT INTO pack_slots (pack_id, slot_number, source_type, source_path, project_id, project_chop_id, sample_id, start, end, display_name, source_chop_updated_at, pitch_shift_semitones, time_stretch_ratio) VALUES - ('seed-pack-hiphop-0000000001', 1, 'project-chop', '$AMEN_PATH', 'seed-proj-amen-000000000001', 'seed-chop-amen-01', NULL, 0.000, 0.550, 'Kick', 1735689600000, NULL, NULL), - ('seed-pack-hiphop-0000000001', 2, 'project-chop', '$AMEN_PATH', 'seed-proj-amen-000000000001', 'seed-chop-amen-02', NULL, 0.550, 1.100, 'Snare 1', 1735689600000, NULL, NULL), - ('seed-pack-hiphop-0000000001', 3, 'project-chop', '$AMEN_PATH', 'seed-proj-amen-000000000001', 'seed-chop-amen-03', NULL, 1.100, 1.660, 'Hi-Hat Closed', 1735689600000, NULL, NULL), - ('seed-pack-hiphop-0000000001', 4, 'project-chop', '$AMEN_PATH', 'seed-proj-amen-000000000001', 'seed-chop-amen-04', NULL, 1.660, 2.210, 'Open Hat', 1735689600000, NULL, NULL), - ('seed-pack-hiphop-0000000001', 5, 'project-chop', '$AMEN_PATH', 'seed-proj-amen-000000000001', 'seed-chop-amen-05', NULL, 2.210, 2.760, 'Kick + Snare', 1735689600000, NULL, NULL), - ('seed-pack-hiphop-0000000001', 6, 'project-chop', '$AMEN_PATH', 'seed-proj-amen-000000000001', 'seed-chop-amen-06', NULL, 2.760, 3.310, 'Ghost Snare', 1735689600000, NULL, NULL), - ('seed-pack-hiphop-0000000001', 7, 'project-chop', '$AMEN_PATH', 'seed-proj-amen-000000000001', 'seed-chop-amen-07', NULL, 3.310, 3.870, 'Snare 2', 1735689600000, NULL, NULL), - ('seed-pack-hiphop-0000000001', 8, 'project-chop', '$AMEN_PATH', 'seed-proj-amen-000000000001', 'seed-chop-amen-08', NULL, 3.870, 4.420, 'Crash + Kick', 1735689600000, NULL, NULL), - ('seed-pack-hiphop-0000000001', 9, 'project-chop', '$THINK_PATH', 'seed-proj-think-00000000002', 'seed-chop-think-01', NULL, 0.000, 0.860, 'Kick (Think)', 1735776000000, NULL, NULL), - ('seed-pack-hiphop-0000000001', 10, 'project-chop', '$THINK_PATH', 'seed-proj-think-00000000002', 'seed-chop-think-02', NULL, 0.860, 1.790, 'Snare (Think)', 1735776000000, NULL, NULL), - ('seed-pack-hiphop-0000000001', 11, 'project-chop', '$THINK_PATH', 'seed-proj-think-00000000002', 'seed-chop-think-04', NULL, 2.560, 3.590, 'Hi-Hat (Think)', 1735776000000, NULL, NULL), - ('seed-pack-hiphop-0000000001', 12, 'project-chop', '$THINK_PATH', 'seed-proj-think-00000000002', 'seed-chop-think-06', NULL, 4.780, 5.970, 'Floor Tom', 1735776000000, NULL, NULL); + ('seed-pack-hiphop-0000000001', 0, 'project-chop', '$AMEN_PATH', 'seed-proj-amen-000000000001', 'seed-chop-amen-01', NULL, 0.000, 0.550, 'Kick', 1735689600000, NULL, NULL), + ('seed-pack-hiphop-0000000001', 1, 'project-chop', '$AMEN_PATH', 'seed-proj-amen-000000000001', 'seed-chop-amen-02', NULL, 0.550, 1.100, 'Snare 1', 1735689600000, NULL, NULL), + ('seed-pack-hiphop-0000000001', 2, 'project-chop', '$AMEN_PATH', 'seed-proj-amen-000000000001', 'seed-chop-amen-03', NULL, 1.100, 1.660, 'Hi-Hat Closed', 1735689600000, NULL, NULL), + ('seed-pack-hiphop-0000000001', 3, 'project-chop', '$AMEN_PATH', 'seed-proj-amen-000000000001', 'seed-chop-amen-04', NULL, 1.660, 2.210, 'Open Hat', 1735689600000, NULL, NULL), + ('seed-pack-hiphop-0000000001', 4, 'project-chop', '$AMEN_PATH', 'seed-proj-amen-000000000001', 'seed-chop-amen-05', NULL, 2.210, 2.760, 'Kick + Snare', 1735689600000, NULL, NULL), + ('seed-pack-hiphop-0000000001', 5, 'project-chop', '$AMEN_PATH', 'seed-proj-amen-000000000001', 'seed-chop-amen-06', NULL, 2.760, 3.310, 'Ghost Snare', 1735689600000, NULL, NULL), + ('seed-pack-hiphop-0000000001', 6, 'project-chop', '$AMEN_PATH', 'seed-proj-amen-000000000001', 'seed-chop-amen-07', NULL, 3.310, 3.870, 'Snare 2', 1735689600000, NULL, NULL), + ('seed-pack-hiphop-0000000001', 7, 'project-chop', '$AMEN_PATH', 'seed-proj-amen-000000000001', 'seed-chop-amen-08', NULL, 3.870, 4.420, 'Crash + Kick', 1735689600000, NULL, NULL), + ('seed-pack-hiphop-0000000001', 8, 'project-chop', '$THINK_PATH', 'seed-proj-think-00000000002', 'seed-chop-think-01', NULL, 0.000, 0.860, 'Kick (Think)', 1735776000000, NULL, NULL), + ('seed-pack-hiphop-0000000001', 8, 'project-chop', '$THINK_PATH', 'seed-proj-think-00000000002', 'seed-chop-think-02', NULL, 0.860, 1.790, 'Snare (Think)', 1735776000000, NULL, NULL), + ('seed-pack-hiphop-0000000001', 9, 'project-chop', '$THINK_PATH', 'seed-proj-think-00000000002', 'seed-chop-think-04', NULL, 2.560, 3.590, 'Hi-Hat (Think)', 1735776000000, NULL, NULL), + ('seed-pack-hiphop-0000000001', 10, 'project-chop', '$THINK_PATH', 'seed-proj-think-00000000002', 'seed-chop-think-06', NULL, 4.780, 5.970, 'Floor Tom', 1735776000000, NULL, NULL); -- Pack 2: Golden Era Drums (Maschine MK3) INSERT INTO packs (id, name, hardware_profile, created_at) VALUES ('seed-pack-golden-000000002', 'Golden Era Drums', 'maschine-mk3', 1735948800000); INSERT INTO pack_slots (pack_id, slot_number, source_type, source_path, project_id, project_chop_id, sample_id, start, end, display_name, source_chop_updated_at, pitch_shift_semitones, time_stretch_ratio) VALUES - ('seed-pack-golden-000000002', 1, 'project-chop', '$THINK_PATH', 'seed-proj-think-00000000002', 'seed-chop-think-01', NULL, 0.000, 0.860, 'Kick', 1735776000000, NULL, NULL), - ('seed-pack-golden-000000002', 2, 'project-chop', '$THINK_PATH', 'seed-proj-think-00000000002', 'seed-chop-think-02', NULL, 0.860, 1.790, 'Snare', 1735776000000, NULL, NULL), - ('seed-pack-golden-000000002', 3, 'project-chop', '$THINK_PATH', 'seed-proj-think-00000000002', 'seed-chop-think-03', NULL, 1.790, 2.560, 'Rim Shot', 1735776000000, NULL, NULL), - ('seed-pack-golden-000000002', 4, 'project-chop', '$THINK_PATH', 'seed-proj-think-00000000002', 'seed-chop-think-04', NULL, 2.560, 3.590, 'Hi-Hat', 1735776000000, NULL, NULL), - ('seed-pack-golden-000000002', 5, 'project-chop', '$THINK_PATH', 'seed-proj-think-00000000002', 'seed-chop-think-05', NULL, 3.590, 4.780, 'Open Hat', 1735776000000, NULL, NULL), - ('seed-pack-golden-000000002', 6, 'project-chop', '$THINK_PATH', 'seed-proj-think-00000000002', 'seed-chop-think-06', NULL, 4.780, 5.970, 'Floor Tom', 1735776000000, NULL, NULL), - ('seed-pack-golden-000000002', 7, 'project-chop', '$THINK_PATH', 'seed-proj-think-00000000002', 'seed-chop-think-07', NULL, 5.970, 7.180, 'Full Bar', 1735776000000, NULL, NULL), - ('seed-pack-golden-000000002', 8, 'project-chop', '$AMEN_PATH', 'seed-proj-amen-000000000001', 'seed-chop-amen-01', NULL, 0.000, 0.550, 'Kick (Amen)', 1735689600000, NULL, NULL), - ('seed-pack-golden-000000002', 9, 'project-chop', '$AMEN_PATH', 'seed-proj-amen-000000000001', 'seed-chop-amen-02', NULL, 0.550, 1.100, 'Snare (Amen)', 1735689600000, NULL, NULL), - ('seed-pack-golden-000000002', 10, 'project-chop', '$AMEN_PATH', 'seed-proj-amen-000000000001', 'seed-chop-amen-04', NULL, 1.660, 2.210, 'Open Hat (Amen)', 1735689600000, NULL, NULL), - ('seed-pack-golden-000000002', 11, 'project-chop', '$AMEN_PATH', 'seed-proj-amen-000000000001', 'seed-chop-amen-06', NULL, 2.760, 3.310, 'Ghost Snare (Amen)', 1735689600000, NULL, NULL); + ('seed-pack-golden-000000002', 0, 'project-chop', '$THINK_PATH', 'seed-proj-think-00000000002', 'seed-chop-think-01', NULL, 0.000, 0.860, 'Kick', 1735776000000, NULL, NULL), + ('seed-pack-golden-000000002', 1, 'project-chop', '$THINK_PATH', 'seed-proj-think-00000000002', 'seed-chop-think-02', NULL, 0.860, 1.790, 'Snare', 1735776000000, NULL, NULL), + ('seed-pack-golden-000000002', 2, 'project-chop', '$THINK_PATH', 'seed-proj-think-00000000002', 'seed-chop-think-03', NULL, 1.790, 2.560, 'Rim Shot', 1735776000000, NULL, NULL), + ('seed-pack-golden-000000002', 3, 'project-chop', '$THINK_PATH', 'seed-proj-think-00000000002', 'seed-chop-think-04', NULL, 2.560, 3.590, 'Hi-Hat', 1735776000000, NULL, NULL), + ('seed-pack-golden-000000002', 4, 'project-chop', '$THINK_PATH', 'seed-proj-think-00000000002', 'seed-chop-think-05', NULL, 3.590, 4.780, 'Open Hat', 1735776000000, NULL, NULL), + ('seed-pack-golden-000000002', 5, 'project-chop', '$THINK_PATH', 'seed-proj-think-00000000002', 'seed-chop-think-06', NULL, 4.780, 5.970, 'Floor Tom', 1735776000000, NULL, NULL), + ('seed-pack-golden-000000002', 6, 'project-chop', '$THINK_PATH', 'seed-proj-think-00000000002', 'seed-chop-think-07', NULL, 5.970, 7.180, 'Full Bar', 1735776000000, NULL, NULL), + ('seed-pack-golden-000000002', 7, 'project-chop', '$AMEN_PATH', 'seed-proj-amen-000000000001', 'seed-chop-amen-01', NULL, 0.000, 0.550, 'Kick (Amen)', 1735689600000, NULL, NULL), + ('seed-pack-golden-000000002', 8, 'project-chop', '$AMEN_PATH', 'seed-proj-amen-000000000001', 'seed-chop-amen-02', NULL, 0.550, 1.100, 'Snare (Amen)', 1735689600000, NULL, NULL), + ('seed-pack-golden-000000002', 8, 'project-chop', '$AMEN_PATH', 'seed-proj-amen-000000000001', 'seed-chop-amen-04', NULL, 1.660, 2.210, 'Open Hat (Amen)', 1735689600000, NULL, NULL), + ('seed-pack-golden-000000002', 9, 'project-chop', '$AMEN_PATH', 'seed-proj-amen-000000000001', 'seed-chop-amen-06', NULL, 2.760, 3.310, 'Ghost Snare (Amen)', 1735689600000, NULL, NULL); " echo "" diff --git a/src/App.tsx b/src/App.tsx index 322d0c6..944bc2b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import Toolbar from '@/components/Toolbar' import AppSidebar from '@/components/AppSidebar' import { Toaster } from '@/components/ui/Toaster' import { CommandPalette } from '@/components/CommandPalette' +import { UpdateBanner } from '@/components/UpdateBanner' import { useUiStore } from '@/stores/ui' import { useProjectsStore } from '@/stores/projects' import { usePacksStore } from '@/stores/packs' @@ -16,13 +17,13 @@ import { toLocalFileUrl, fileNameFromPath, mimeTypeFromPath } from '@/utils' export default function App() { const { currentView } = useUiStore() const { fetchProjects, setActiveProject } = useProjectsStore() - const { fetchPacks, setCurrentPack } = usePacksStore() + const { fetchPacks, fetchProfiles, setCurrentPack } = usePacksStore() const { fetchSamples } = useLibraryStore() const { setAudio } = usePlayerStore() useEffect(() => { async function init() { - await Promise.all([fetchProjects(), fetchPacks(), fetchSamples()]) + await Promise.all([fetchProjects(), fetchPacks(), fetchProfiles(), fetchSamples()]) const { projects, activeProject } = useProjectsStore.getState() if (!activeProject && projects.length > 0) { @@ -49,8 +50,13 @@ export default function App() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []) + // The one-time chop backfill runs in the background after launch (F14); refetch the library when + // it reports new samples so an upgrade populates Browse without a manual reload. + useEffect(() => window.api.events.onLibraryChanged(() => { fetchSamples() }), [fetchSamples]) + return (
+
diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index 8497383..e23cda1 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -228,6 +228,8 @@ function LibraryContent() { ? `Imported ${imported} sample${imported !== 1 ? 's' : ''} (${skipped} skipped)` : `Imported ${imported} sample${imported !== 1 ? 's' : ''}` toast(msg, imported === 0 ? 'info' : 'success') + } catch (err) { + toast(`Import failed: ${err instanceof Error ? err.message : 'unknown error'}`, 'error') } finally { setImporting(false) } diff --git a/src/components/AudioWaveform.tsx b/src/components/AudioWaveform.tsx index 1132dad..dcbdb80 100644 --- a/src/components/AudioWaveform.tsx +++ b/src/components/AudioWaveform.tsx @@ -34,7 +34,7 @@ import { import { rankTransientsFromUrl, findLoopCandidatesFromUrl, type RankedPeak } from '@/lib/audioAnalysis' import { remapRegionsForTrim } from '@/lib/remapRegions' import { cn } from '@/lib/utils' -import { formatTime, toLocalFileUrl, defaultChopName } from '@/utils' +import { formatTime, toLocalFileUrl, defaultChopName, modLabel, modShiftLabel } from '@/utils' import type { ProjectRegion, StemName } from '@/types' import type { Region } from 'wavesurfer.js/dist/plugins/regions' @@ -229,6 +229,7 @@ const AudioWaveform = ({ audioUrl, audioName, filePath, size, type, initialRegio projectName, audioName, source, + attribution: audio?.freesound ?? null, autosaveActiveRegions, }) @@ -244,7 +245,8 @@ const AudioWaveform = ({ audioUrl, audioName, filePath, size, type, initialRegio if (!project) return const pack = await createPack(`${project.name} Pack`, hardwareProfileId) - const chops = (await window.api.projects.getChops(project.id)).slice(0, 16) + const allChops = await window.api.projects.getChops(project.id) + const chops = allChops.slice(0, 16) for (const [index, chop] of chops.entries()) { await setSlot(index, { id: `project-chop:${chop.id}`, @@ -265,7 +267,13 @@ const AudioWaveform = ({ audioUrl, audioName, filePath, size, type, initialRegio }) } setView('packs') - toast(`${chops.length} chop${chops.length !== 1 ? 's' : ''} sent to ${pack.name}`) + const truncated = allChops.length > chops.length + toast( + `${chops.length} chop${chops.length !== 1 ? 's' : ''} sent to ${pack.name}` + + (truncated ? ` (${allChops.length - chops.length} skipped — 16-pad limit)` : '') + ) + } catch (err) { + toast(`Send to Pack failed: ${err instanceof Error ? err.message : 'unknown error'}`) } finally { setIsSaving(false) } @@ -558,9 +566,12 @@ const AudioWaveform = ({ audioUrl, audioName, filePath, size, type, initialRegio const actions = ( <> {saveStatus !== 'idle' && ( - + {saveStatus === 'saved' && } - {saveStatus === 'saving' ? 'Saving…' : 'Saved'} + {saveStatus === 'error' ? 'Save failed — edits not saved' : saveStatus === 'saving' ? 'Saving…' : 'Saved'} )} diff --git a/src/components/Nav.tsx b/src/components/Nav.tsx deleted file mode 100644 index 5e8a91e..0000000 --- a/src/components/Nav.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { Scissors, Library, Grid2x2 } from 'lucide-react' -import { cn } from '@/lib/utils' -import { useUiStore } from '@/stores/ui' - -const tabs = [ - { id: 'chop', label: 'Chop', icon: Scissors }, - { id: 'library', label: 'Library', icon: Library }, - { id: 'packs', label: 'Packs', icon: Grid2x2 }, -] as const - -export default function Nav() { - const { currentView, setView, sidebarOpen } = useUiStore() - - return ( - - ) -} diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 6aba9ee..660cd4c 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -1,6 +1,7 @@ import { PanelLeftClose, PanelLeftOpen } from 'lucide-react' import { useUiStore } from '@/stores/ui' import { Segmented } from '@/components/ui/Segmented' +import { modLabel } from '@/utils' type View = 'chop' | 'library' | 'packs' @@ -61,10 +62,10 @@ export default function Toolbar() { >
diff --git a/src/components/UpdateBanner.tsx b/src/components/UpdateBanner.tsx new file mode 100644 index 0000000..e313b6c --- /dev/null +++ b/src/components/UpdateBanner.tsx @@ -0,0 +1,61 @@ +import { useEffect, useState } from 'react' +import { Download, RefreshCw } from 'lucide-react' +import { Button } from '@/components/ui/Button' +import { useToastStore } from '@/stores/toast' + +type Status = 'idle' | 'available' | 'downloading' | 'ready' + +// Surfaces the auto-updater to the user (F18). Checks once on mount and then reacts to the updater's +// push events. Dormant in dev / when no update exists (status stays 'idle', nothing renders). +export function UpdateBanner() { + const [status, setStatus] = useState('idle') + const [newVersion, setNewVersion] = useState() + const [percent, setPercent] = useState(0) + const { toast } = useToastStore() + + useEffect(() => { + const unsubs = [ + window.api.events.onUpdateAvailable((info) => { setNewVersion(info.newVersion); setStatus('available') }), + window.api.events.onUpdateProgress((p) => { setPercent(Math.round(p)); setStatus('downloading') }), + window.api.events.onUpdateDownloaded(() => setStatus('ready')), + window.api.events.onUpdateError((message) => toast(`Update failed: ${message}`, 'error')), + ] + + // Fire-and-forget: an available update also arrives via onUpdateAvailable, but checking here + // covers the case where the check resolves before the event listener is attached. + window.api.updates.check().then((result) => { + if ('available' in result && result.available) { + setNewVersion(result.newVersion) + setStatus('available') + } + }) + + return () => unsubs.forEach((u) => u()) + }, [toast]) + + if (status === 'idle') return null + + return ( +
+ {status === 'available' && ( + <> + A new version{newVersion ? ` (${newVersion})` : ''} is available. + + + )} + {status === 'downloading' && Downloading update… {percent}%} + {status === 'ready' && ( + <> + Update ready. + + + )} +
+ ) +} diff --git a/src/hooks/useChopAutosave.ts b/src/hooks/useChopAutosave.ts index 417a84c..b48b9b3 100644 --- a/src/hooks/useChopAutosave.ts +++ b/src/hooks/useChopAutosave.ts @@ -14,9 +14,11 @@ interface UseChopAutosaveProps { projectName: string audioName: string source: 'local' | 'freesound' + // Freesound attribution for the current source, persisted onto the project on first save (F24). + attribution?: { id: string; license: string; author: string } | null autosaveActiveRegions: ( regions: ProjectRegion[], - fallback: { name: string; sourcePath: string | null; sourceName?: string | null; source?: 'local' | 'freesound' } + fallback: { name: string; sourcePath: string | null; sourceName?: string | null; source?: 'local' | 'freesound'; freesoundId?: string | null; license?: string | null; author?: string | null } ) => Promise } @@ -32,9 +34,10 @@ export function useChopAutosave({ projectName, audioName, source, + attribution, autosaveActiveRegions, }: UseChopAutosaveProps) { - const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle') + const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle') const autosaveTimer = useRef(null) const saveStatusTimer = useRef(null) const lastSavedAt = useRef(0) @@ -42,8 +45,14 @@ export function useChopAutosave({ const markSaved = useCallback(() => { lastSavedAt.current = Date.now() }, []) + // Undefined means the regions plugin isn't mounted yet; an empty array is a real, saveable state. + const regionsReady = regions !== undefined + useEffect(() => { - if (!filePath || !regions?.length) return + // Only bail when the regions plugin isn't ready yet. An empty array is a real, saveable state — + // deleting the last chop or "Clear all" must persist, not be dropped (F5). `revision` re-runs + // this effect on every region edit, including the transition to zero regions. + if (!filePath || !regionsReady) return if (autosaveTimer.current !== null) window.clearTimeout(autosaveTimer.current) const isFirst = isFirstAutosave.current @@ -60,6 +69,9 @@ export function useChopAutosave({ sourcePath: filePath, sourceName: audioName, source, + freesoundId: attribution?.id ?? null, + license: attribution?.license ?? null, + author: attribution?.author ?? null, }).then(() => { lastSavedAt.current = Date.now() if (!isFirst) { @@ -67,13 +79,17 @@ export function useChopAutosave({ if (saveStatusTimer.current !== null) window.clearTimeout(saveStatusTimer.current) saveStatusTimer.current = window.setTimeout(() => setSaveStatus('idle'), 2000) } - }).catch(() => setSaveStatus('idle')) + }).catch(() => { + // Surface failures instead of masking them as idle: a failed save (DB error, ffmpeg + // missing) left edits unpersisted, and the header must not keep implying "Saved" (F12). + setSaveStatus('error') + }) }, delay) return () => { if (autosaveTimer.current !== null) window.clearTimeout(autosaveTimer.current) } - }, [audioName, autosaveActiveRegions, currentRegions, filePath, projectName, regions?.length, revision, source]) + }, [attribution, audioName, autosaveActiveRegions, currentRegions, filePath, projectName, regionsReady, revision, source]) return { saveStatus, markSaved } } diff --git a/src/hooks/useShortcuts.ts b/src/hooks/useShortcuts.ts index 0f88de2..6a815f8 100644 --- a/src/hooks/useShortcuts.ts +++ b/src/hooks/useShortcuts.ts @@ -27,13 +27,9 @@ export const useShortcuts = ({ onUndo, onRedo, }: UseShortcutsProps) => { - // const regionsPlugin = getRegionsPlugin(wavesurfer); - - const handlePressTab = useCallback(() => {}, []); const handlePressBackspace = useCallback(() => { selectedRegion?.remove(); }, [selectedRegion]); - const handlePressEscape = useCallback(() => {}, []); const handlePressSpace = useCallback(() => { if (wavesurfer?.isPlaying()) { wavesurfer.pause(); @@ -93,9 +89,7 @@ export const useShortcuts = ({ return; } if (key === " ") { event.preventDefault(); handlePressSpace(); } - if (key === "Escape") handlePressEscape(); if (key === "Backspace") handlePressBackspace(); - if (key === "Tab") handlePressTab(); if (key === "ArrowUp") { event.preventDefault(); handleSelectAdjacentRegion(-1); @@ -112,10 +106,8 @@ export const useShortcuts = ({ document.removeEventListener("keydown", handleKeyDown); }; }, [ - handlePressEscape, handlePressSpace, handlePressBackspace, - handlePressTab, handleSelectAdjacentRegion, onRedo, onUndo, diff --git a/src/lib/audioAnalysis.ts b/src/lib/audioAnalysis.ts index 6f38fb1..c37dded 100644 --- a/src/lib/audioAnalysis.ts +++ b/src/lib/audioAnalysis.ts @@ -72,14 +72,6 @@ export function analyzeAudioUrl(url: string): Promise { return result } -export async function detectTransientsFromUrl( - url: string, - preset: 'coarse' | 'medium' | 'fine' -): Promise { - const buffer = await getAudioBuffer(url) - return runOnWorker({ kind: 'transients', sampleRate: buffer.sampleRate, preset }, copyChannels(buffer)) -} - export async function rankTransientsFromUrl(url: string, minGap = 0.12): Promise { const buffer = await getAudioBuffer(url) return runOnWorker({ kind: 'rank', sampleRate: buffer.sampleRate, minGap }, copyChannels(buffer)) diff --git a/src/stores/freesound.ts b/src/stores/freesound.ts index b4ed3ee..56785fa 100644 --- a/src/stores/freesound.ts +++ b/src/stores/freesound.ts @@ -1,7 +1,19 @@ import { create } from 'zustand' import { withLoading } from './utils' +import { useToastStore } from './toast' import type { FreesoundResult } from '@/types' +// Turn a thrown Freesound/network error into a user-facing toast. Rejections used to escape +// withLoading uncaught, leaving the spinner to stop on an empty list with zero feedback (F11). +function reportFreesoundError(err: unknown): void { + const message = err instanceof Error ? err.message : String(err) + if (/\b401\b/.test(message)) { + useToastStore.getState().toast('Freesound rejected the request — check your API key in Settings', 'error') + } else { + useToastStore.getState().toast(`Freesound search failed: ${message}`, 'error') + } +} + export type FreesoundSort = 'score' | 'downloads_desc' | 'rating_desc' | 'created_desc' export type FreesoundDuration = 'any' | 'short' | 'medium' | 'long' @@ -46,8 +58,12 @@ export const useFreesoundStore = create((set, get) => ({ await withLoading( (v) => set({ isSearching: v }), async () => { - const data = await window.api.freesound.search(query, 1, sort, DURATION_FILTER[durationFilter]) - set({ results: data.results, hasMore: data.next !== null, page: 1 }) + try { + const data = await window.api.freesound.search(query, 1, sort, DURATION_FILTER[durationFilter]) + set({ results: data.results, hasMore: data.next !== null, page: 1 }) + } catch (err) { + reportFreesoundError(err) + } } ) }, @@ -71,12 +87,16 @@ export const useFreesoundStore = create((set, get) => ({ await withLoading( (v) => set({ isSearching: v }), async () => { - const data = await window.api.freesound.search(query, nextPage, sort, DURATION_FILTER[durationFilter]) - set((s) => ({ - results: [...s.results, ...data.results], - hasMore: data.next !== null, - page: nextPage, - })) + try { + const data = await window.api.freesound.search(query, nextPage, sort, DURATION_FILTER[durationFilter]) + set((s) => ({ + results: [...s.results, ...data.results], + hasMore: data.next !== null, + page: nextPage, + })) + } catch (err) { + reportFreesoundError(err) + } } ) }, diff --git a/src/stores/library.ts b/src/stores/library.ts index 5dceedf..c1c3343 100644 --- a/src/stores/library.ts +++ b/src/stores/library.ts @@ -26,7 +26,6 @@ type LibraryState = { addSample: (data: { name: string; filePath: string; duration?: number }) => Promise updateSample: (id: string, data: Partial>) => Promise deleteSample: (id: string) => Promise - saveChops: (params: { sourceFilePath: string; regions: Array<{ start: number; end: number; name: string }>; projectId?: string }) => Promise importFolder: (folderPath: string) => Promise<{ imported: number; skipped: number }> setSearchQuery: (query: string) => void setFilters: (filters: Filters) => void @@ -37,7 +36,7 @@ type LibraryState = { // Decode + analyse freshly created samples off the critical path, patching BPM/key/waveform back // in as each finishes. Fire-and-forget: callers don't await it; the rows just gain their analysed // fields a moment later. Bounded by ANALYSIS_CONCURRENCY; a failed analysis is non-fatal (the -// sample keeps its un-analysed defaults). Shared by importFolder and saveChops. +// sample keeps its un-analysed defaults). function backfillAnalysis(samples: Sample[], updateSample: LibraryState['updateSample']): void { void forEachConcurrent(samples, ANALYSIS_CONCURRENCY, async (sample) => { try { @@ -93,13 +92,6 @@ export const useLibraryStore = create((set, get) => ({ return result }, - saveChops: async (params) => { - const saved = await window.api.library.saveChops(params) - const samples = await window.api.library.getSamples() - set({ samples }) - backfillAnalysis(saved, get().updateSample) - }, - setSearchQuery: (searchQuery) => set({ searchQuery }), setFilters: (filters) => set({ filters }), setProjectFilter: (projectFilter) => set({ projectFilter }), diff --git a/src/stores/packs.ts b/src/stores/packs.ts index b4f1945..924a4d9 100644 --- a/src/stores/packs.ts +++ b/src/stores/packs.ts @@ -1,14 +1,19 @@ import { create } from 'zustand' import type { Pack, PackSlot, PackSourceItem } from '../../electron/types' +export type HardwareProfileOption = { id: string; name: string; padCount: number } + type PacksState = { packs: Pack[] currentPack: Pack | null slots: Record hardwareProfileId: string - exportProgress: number | null + // Single source of truth for hardware profiles, fetched from main (packs:getProfiles) rather than + // a second hardcoded copy in the view that drifts from main/README (F19a). + profiles: HardwareProfileOption[] fetchPacks: () => Promise + fetchProfiles: () => Promise createPack: (name: string, hardwareProfile: string) => Promise renamePack: (id: string, name: string) => Promise setCurrentPack: (pack: Pack | null) => void @@ -17,7 +22,7 @@ type PacksState = { setSlot: (slotNumber: number, source: PackSourceItem) => Promise clearSlot: (slotNumber: number) => void setHardwareProfile: (profileId: string) => void - exportPack: (outputDir: string) => Promise<{ filesWritten: number }> + exportPack: (outputDir: string) => Promise<{ filesWritten: number; failed: number }> deletePack: (id: string) => Promise } @@ -26,13 +31,18 @@ export const usePacksStore = create((set, get) => ({ currentPack: null, slots: {}, hardwareProfileId: 'maschine-mk3', - exportProgress: null, + profiles: [], fetchPacks: async () => { const packs = await window.api.packs.getAll() set({ packs }) }, + fetchProfiles: async () => { + const profiles = await window.api.packs.getProfiles() + set({ profiles }) + }, + createPack: async (name, hardwareProfile) => { const pack = await window.api.packs.create({ name, hardwareProfile }) set((state) => ({ packs: [pack, ...state.packs], currentPack: pack })) @@ -73,8 +83,6 @@ export const usePacksStore = create((set, get) => ({ end: source.end, displayName: source.displayName, sourceChopUpdatedAt: source.sourceChopUpdatedAt, - pitchShiftSemitones: null, - timeStretchRatio: null, // Owned audio is materialized server-side; only export reads it (from the DB), so the // optimistic local slot can leave it null until the next loadSlots. audioPath: null, diff --git a/src/stores/player.ts b/src/stores/player.ts index 0d41efc..ad01d59 100644 --- a/src/stores/player.ts +++ b/src/stores/player.ts @@ -8,6 +8,8 @@ export type AudioSource = { size: number type: string source: 'local' | 'freesound' + /** Freesound attribution, set when the source is a Freesound download so it can be persisted (F24). */ + freesound?: { id: string; license: string; author: string } /** Regions to restore on the next waveform mount (e.g. after trim). */ initialRegions?: ProjectRegion[] } diff --git a/src/stores/projects.ts b/src/stores/projects.ts index 506743a..50af3e1 100644 --- a/src/stores/projects.ts +++ b/src/stores/projects.ts @@ -2,6 +2,12 @@ import { create } from 'zustand' import { withLoading } from './utils' import type { Project, ProjectRegion } from '@/types' +// Create-once latch for autosave. The first save is slow (projects.save renders every chop through +// ffmpeg before resolving) and activeProject stays null until it settles, so a second fast edit used +// to fire a second projects.save and duplicate the project (F3). Concurrent autosaves now await the +// same in-flight create, then fall through to a cheap upsert of their newer regions. +let creatingProject: Promise | null = null + interface ProjectsState { projects: Project[] activeProject: Project | null @@ -12,7 +18,7 @@ interface ProjectsState { saveProject: (data: { name: string; sourcePath: string; sourceName?: string | null; regions: ProjectRegion[] }) => Promise updateActiveProject: () => Promise updateActiveRegions: (regions: ProjectRegion[]) => Promise - autosaveActiveRegions: (regions: ProjectRegion[], fallback: { name: string; sourcePath: string | null; sourceName?: string | null; source?: 'local' | 'freesound' }) => Promise + autosaveActiveRegions: (regions: ProjectRegion[], fallback: { name: string; sourcePath: string | null; sourceName?: string | null; source?: 'local' | 'freesound'; freesoundId?: string | null; license?: string | null; author?: string | null }) => Promise applyLocalTrim: (data: { sourcePath: string; regions: ProjectRegion[] }) => void renameProject: (id: string, name: string) => Promise duplicateProject: (id: string) => Promise @@ -76,19 +82,34 @@ export const useProjectsStore = create((set, get) => ({ let { activeProject } = get() if (!activeProject) { if (!fallback.sourcePath) return null - activeProject = await window.api.projects.save({ - name: fallback.name.trim() || 'Untitled Project', - sourcePath: fallback.sourcePath, - sourceName: fallback.sourceName ?? null, - source: fallback.source ?? 'local', - regions, - }) - set((s) => ({ - projects: [activeProject!, ...s.projects], - activeProject, - isProjectDirty: false, - })) - return activeProject + // Nothing to persist yet, and creating an empty project just from opening a file is wrong. + if (regions.length === 0) return null + + if (creatingProject) { + // A create is already in flight for this session; wait for it, then persist our (newer) + // regions through the upsert path below instead of racing a second projects.save. + activeProject = await creatingProject + if (!activeProject) return null + } else { + const create = window.api.projects + .save({ + name: fallback.name.trim() || 'Untitled Project', + sourcePath: fallback.sourcePath, + sourceName: fallback.sourceName ?? null, + source: fallback.source ?? 'local', + freesoundId: fallback.freesoundId ?? null, + license: fallback.license ?? null, + author: fallback.author ?? null, + regions, + }) + .then((saved) => { + set((s) => ({ projects: [saved, ...s.projects], activeProject: saved, isProjectDirty: false })) + return saved as Project | null + }) + creatingProject = create + create.finally(() => { if (creatingProject === create) creatingProject = null }) + return await create + } } const saved = await window.api.projects.upsertChops(activeProject.id, regions) diff --git a/src/stores/stems.ts b/src/stores/stems.ts index 1b178d7..097abb0 100644 --- a/src/stores/stems.ts +++ b/src/stores/stems.ts @@ -42,12 +42,22 @@ let worker: Worker | null = null let readyPromise: Promise | null = null let onProgress: ((v: number) => void) | null = null let pendingResult: { resolve: (s: StemSet) => void; reject: (e: Error) => void } | null = null +// Monotonic run token. Each separate() run claims one; a superseding run or cancel() bumps it so a +// stale run's post-await steps no-op instead of clobbering shared state or the UI (F8). +let activeRunId = 0 + +// The demucs build is a 32-bit-heap Emscripten module; a very long track (in + 4 stems out + model) +// overruns it and aborts with no useful message. Cap up front with a clear error instead. +const MAX_STEM_SECONDS = 10 * 60 function disposeWorker() { worker?.terminate() worker = null readyPromise = null + // Never leave an awaiting separate() hanging on a promise that can no longer settle (F8). + pendingResult?.reject(new Error('stem separation cancelled')) pendingResult = null + onProgress = null } function toArrayBuffer(u: Uint8Array): ArrayBuffer { @@ -125,13 +135,21 @@ export const useStemsStore = create((set, get) => ({ originalSource: null, separate: async (source) => { + // Claim a run token. Any earlier in-flight run is superseded: reset the worker so its late + // result can't cross-wire into this run, which also rejects that run's pending promise. + const runId = ++activeRunId + if (pendingResult || worker) disposeWorker() + const superseded = () => activeRunId !== runId + set({ status: 'decoding', progress: 0, error: null, stems: null, selected: null, originalSource: source }) try { const bytes = await (await fetch(source.path)).arrayBuffer() const sourceHash = await sha256Hex(bytes) + if (superseded()) return set({ sourceHash }) const cached = await window.api.stems.getCached(sourceHash) + if (superseded()) return if (cached) { set({ status: 'done', progress: 1, stems: cached }) return @@ -139,18 +157,25 @@ export const useStemsStore = create((set, get) => ({ set({ status: 'loading-model' }) const w = await ensureWorker() + if (superseded()) return set({ status: 'decoding' }) const { left, right } = await decodeToStereo(bytes) + if (superseded()) return + + if (left.length / 44100 > MAX_STEM_SECONDS) { + throw new Error(`Track is too long to separate (max ${MAX_STEM_SECONDS / 60} minutes)`) + } set({ status: 'separating', progress: 0.02 }) - onProgress = (v) => set({ progress: Math.max(0.02, v) }) + onProgress = (v) => { if (!superseded()) set({ progress: Math.max(0.02, v) }) } const stemSet = await new Promise((resolve, reject) => { pendingResult = { resolve, reject } w.postMessage({ type: 'separate', left, right }, [left.buffer, right.buffer]) }) onProgress = null pendingResult = null + if (superseded()) return set({ status: 'persisting', progress: 1 }) const pcm = STEM_ORDER.map((name) => ({ @@ -163,9 +188,12 @@ export const useStemsStore = create((set, get) => ({ sourceHash, pcm, ) + if (superseded()) return set({ status: 'done', stems: files }) } catch (err) { - set({ status: 'error', error: err instanceof Error ? err.message : String(err) }) + // A superseded/cancelled run rejecting is expected — don't surface it as an error over the + // run that replaced it. + if (!superseded()) set({ status: 'error', error: err instanceof Error ? err.message : String(err) }) } }, @@ -193,8 +221,10 @@ export const useStemsStore = create((set, get) => ({ }, cancel: () => { + // Invalidate the in-flight run first so its rejected promise is treated as cancellation, then + // tear down the worker (which rejects the pending result so separate() stops awaiting). + activeRunId++ disposeWorker() - onProgress = null set({ status: 'idle', progress: 0, error: null }) }, diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 068238c..2e2b3c4 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -1,8 +1,8 @@ -import type { Api } from '../../electron/ipc-contract' +import type { Api, ApiEvents } from '../../electron/ipc-contract' declare global { interface Window { - api: Api + api: Api & { events: ApiEvents } } } diff --git a/src/types/index.ts b/src/types/index.ts index ad938e2..435b1a8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -6,7 +6,6 @@ export type { ProjectRegion, ProjectChop, PackSourceItem, - ExportRegionsParams, FreesoundResult, FreesoundPage, StemName, diff --git a/src/utils/index.ts b/src/utils/index.ts index 6548471..3447039 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,6 +1,13 @@ import type WaveSurfer from "wavesurfer.js"; import RegionsPlugin from "wavesurfer.js/dist/plugins/regions"; +// The shortcut handlers accept both Cmd and Ctrl, but the labels used to show ⌘ only — wrong on the +// shipped Windows build (F26). Format modifier hints per platform. +export const IS_MAC = + typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent); +export const modLabel = (key: string): string => (IS_MAC ? `⌘${key}` : `Ctrl+${key}`); +export const modShiftLabel = (key: string): string => (IS_MAC ? `⇧⌘${key}` : `Ctrl+Shift+${key}`); + export const getRegionsPlugin = (wavesurfer?: WaveSurfer) => { const regionsPluginInstance = wavesurfer ?.getActivePlugins() @@ -35,10 +42,18 @@ export const mimeTypeFromPath = (filePath: string): string => { } export const fileNameFromPath = (filePath: string): string => - filePath.split('/').pop() ?? 'audio' + // Split on both separators so a Windows path (C:\...\track.mp3) yields the basename, not the whole + // string (F34). + filePath.split(/[\\/]/).pop() ?? 'audio' export const toLocalFileUrl = (filePath: string): string => { - const encodedPath = filePath.split('/').map(encodeURIComponent).join('/') + // Normalize Windows separators to '/' so a path like C:\Users\me\track.mp3 produces a valid URL + // instead of one whose backslashes get mangled into the authority (F34). Build with an empty + // authority (leading '/') so the drive letter lives in the path, and the main-process protocol + // handler reconstructs it (stripping the leading slash before a drive letter). + const normalized = filePath.replace(/\\/g, '/') + const withLeadingSlash = normalized.startsWith('/') ? normalized : `/${normalized}` + const encodedPath = withLeadingSlash.split('/').map(encodeURIComponent).join('/') return `local-file://${encodedPath}` } @@ -66,26 +81,3 @@ export const formatTime = (seconds: number) => export const defaultChopName = (projectName: string, index: number): string => `${projectName.trim() || "Chop"} ${index + 1}`; -export const convertBlobUrlToArrayBuffer = async ( - blobUrl: string -): Promise => { - try { - const response = await fetch(blobUrl); - const blob = await response.blob(); - return await new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onloadend = () => { - if (reader.result instanceof ArrayBuffer) { - resolve(reader.result); - } else { - reject(new Error("Expected an ArrayBuffer")); - } - }; - reader.onerror = reject; - reader.readAsArrayBuffer(blob); // Lee el blob como ArrayBuffer - }); - } catch (error) { - console.error("Error converting blob URL to ArrayBuffer:", error); - throw error; - } -}; diff --git a/src/views/Library/index.tsx b/src/views/Library/index.tsx index c51b280..c7df1cb 100644 --- a/src/views/Library/index.tsx +++ b/src/views/Library/index.tsx @@ -274,7 +274,7 @@ export default function LibraryView() { Delete "{pendingDelete?.item.name}"? {pendingDelete && pendingDelete.packRefs > 0 ? (

- This sound is used in {pendingDelete.packRefs} pack slot{pendingDelete.packRefs !== 1 ? 's' : ''}. Deleting it will remove those slots permanently. + This sound is used in {pendingDelete.packRefs} pack slot{pendingDelete.packRefs !== 1 ? 's' : ''}. Those pads keep their own audio and still export; you can rebuild the library sample from a pad later.

) : (

@@ -297,7 +297,7 @@ export default function LibraryView() { Delete {bulkDelete?.items.length} sound{bulkDelete && bulkDelete.items.length !== 1 ? 's' : ''}? {bulkDelete && bulkDelete.packRefs > 0 ? (

- {bulkDelete.packRefs} pack slot{bulkDelete.packRefs !== 1 ? 's' : ''} reference these sounds. Deleting will remove those slots permanently. + {bulkDelete.packRefs} pack slot{bulkDelete.packRefs !== 1 ? 's' : ''} reference these sounds. Those pads keep their own audio and still export; you can rebuild library samples from them later.

) : (

@@ -463,6 +463,16 @@ const LibraryRow = memo(function LibraryRow({ onDoubleClick={(e) => { e.stopPropagation(); onDoubleClickName(item) }} > {item.name} + {item.sample.license && ( + // Attribution indicator for Creative Commons sources; full credit on hover, and it's + // written to credits.txt on export (F24). + + CC + + )} )}

diff --git a/src/views/Packs/index.tsx b/src/views/Packs/index.tsx index 521e8c3..a875cab 100644 --- a/src/views/Packs/index.tsx +++ b/src/views/Packs/index.tsx @@ -24,20 +24,13 @@ type PadRecovery = { sample?: Sample } -const PROFILES = [ - { id: 'sp404-mkii', name: 'Roland SP-404 MkII' }, - { id: 'mpc-generic', name: 'Akai MPC One' }, - { id: 'maschine-mk3', name: 'Maschine MK3' }, - { id: 'generic', name: 'Generic WAV' }, -] - // Source row pitch in px: DraggableSource is h-[28px] with a 1px flex gap. The source panel is // virtualized against this so a 900+ sample library mounts only the visible dnd-kit draggables. const SOURCE_ROW_H = 29 const SOURCE_OVERSCAN = 8 export default function PacksView() { - const { currentPack, slots, hardwareProfileId, fetchPacks, setSlot, clearSlot, exportPack, setHardwareProfile, loadSlots } = usePacksStore() + const { currentPack, slots, hardwareProfileId, profiles, fetchPacks, setSlot, clearSlot, exportPack, setHardwareProfile, loadSlots } = usePacksStore() const { padAuditionMode, setPadAuditionMode } = useUiStore() const { samples, fetchSamples } = useLibraryStore() const { projects, fetchProjects } = useProjectsStore() @@ -62,7 +55,10 @@ export default function PacksView() { setIsExporting(true) try { const result = await exportPack(outputDir) - toast(`${result.filesWritten} file${result.filesWritten !== 1 ? 's' : ''} exported`) + const base = `${result.filesWritten} file${result.filesWritten !== 1 ? 's' : ''} exported` + toast(result.failed > 0 ? `${base}, ${result.failed} failed` : base) + } catch (err) { + toast(`Export failed: ${err instanceof Error ? err.message : 'unknown error'}`) } finally { setIsExporting(false) } @@ -142,7 +138,11 @@ export default function PacksView() { if (!over || !currentPack) return const slotNumber = Number(over.id) const source = sourceItems.find((s) => s.id === active.id) - if (source !== undefined) setSlot(slotNumber, source).then(flashPackSaved) + if (source !== undefined) { + setSlot(slotNumber, source) + .then(flashPackSaved) + .catch((err) => toast(`Couldn't assign pad: ${err instanceof Error ? err.message : 'unknown error'}`)) + } } const handleClearSlot = async (slotNumber: number) => { @@ -160,18 +160,26 @@ export default function PacksView() { source = sampleToSourceItem(rec.sample) } if (!source) return - await setSlot(slot.slotNumber, source) - flashPackSaved() - toast(`${slot.displayName} updated from source`) + try { + await setSlot(slot.slotNumber, source) + flashPackSaved() + toast(`${slot.displayName} updated from source`) + } catch (err) { + toast(`Couldn't update pad: ${err instanceof Error ? err.message : 'unknown error'}`) + } } const regenerateSlot = async (rec: PadRecovery) => { if (!currentPack) return - await window.api.packs.regenerateSlotToLibrary(currentPack.id, rec.slotNumber) - await fetchSamples() - await loadSlots() - flashPackSaved() - toast(`${rec.slot.displayName} regenerated to library`) + try { + await window.api.packs.regenerateSlotToLibrary(currentPack.id, rec.slotNumber) + await fetchSamples() + await loadSlots() + flashPackSaved() + toast(`${rec.slot.displayName} regenerated to library`) + } catch (err) { + toast(`Couldn't regenerate pad: ${err instanceof Error ? err.message : 'unknown error'}`) + } } const filledSlots = Object.keys(slots).length @@ -281,7 +289,7 @@ export default function PacksView() { onChange={(e) => setHardwareProfile(e.target.value)} className="appearance-none bg-raised border border-border rounded-md pl-2.5 pr-5 h-[26px] text-[12px] text-ink focus:outline-none focus:border-accent/40 transition-colors cursor-pointer" > - {PROFILES.map((p) => ( + {profiles.map((p) => ( ))} @@ -503,10 +511,13 @@ function PadSlot({ slotNumber, slot, recovery, onClear, isDraggingAny, auditionM auditionMode: PadAuditionMode }) { const { isOver, setNodeRef } = useDroppable({ id: slotNumber }) - // Preview the chop bounds, not the whole source. In Gate, releasing stops; in One-Shot, release is - // ignored and playback runs to region.end (handled inside useAudioPlayer). - const region = slot && slot.start !== null && slot.end !== null ? { start: slot.start, end: slot.end } : null - const { isPlaying, play, stop } = useAudioPlayer(slot ? toLocalFileUrl(slot.sourcePath) : null, region) + // Audition the pad's own snapshot (the pre-trimmed owned WAV), so it stays audible even if the + // source file was moved/deleted and matches exactly what export writes (F13). Legacy pads with no + // owned audio fall back to trimming the source by region. In Gate, releasing stops; in One-Shot, + // release is ignored and playback runs to region.end (handled inside useAudioPlayer). + const auditionPath = slot?.audioPath ?? slot?.sourcePath ?? null + const region = !slot?.audioPath && slot && slot.start !== null && slot.end !== null ? { start: slot.start, end: slot.end } : null + const { isPlaying, play, stop } = useAudioPlayer(auditionPath ? toLocalFileUrl(auditionPath) : null, region) const releaseStop = auditionMode === 'gate' ? stop : undefined const padLabel = String(slotNumber + 1).padStart(2, '0')