From 5a06f546b087d74e13d05eaf8b52e8ffec7704ba Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 12:19:21 +0000 Subject: [PATCH 1/3] docs: adversarial codebase audit (2026-07-03) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MgVgoUZPub3bKYkz6J28yn --- .harness/qa/adversarial-audit-2026-07-03.md | 500 ++++++++++++++++++++ 1 file changed, 500 insertions(+) create mode 100644 .harness/qa/adversarial-audit-2026-07-03.md 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..b4c54da --- /dev/null +++ b/.harness/qa/adversarial-audit-2026-07-03.md @@ -0,0 +1,500 @@ +# SampleByte — Adversarial Codebase Audit (2026-07-03) + +Auditor: senior-staff-engineer pass over the full repository (every source file under +`electron/`, `src/`, `scripts/`, `test/`, `.github/`, plus configs and docs). No loyalty to the +current design. Findings are marked **CONFIRMED** (traced end-to-end in code, or provable from +tool semantics) or **PLAUSIBLE** (strong reasoning, needs a live run to prove — this environment +has no node_modules/ffmpeg/display, and the `.harness/` ADRs are age-encrypted, so ADR references +below are by title only). + +--- + +## 1. System map + +### Processes and boundaries + +- **Main process** (`electron/main/index.ts`): window + splash, custom `local-file://` protocol + (CORS-open file server for the renderer), production CSP injection, DB init + (`electron/main/db/index.ts`, better-sqlite3, WAL, migrations run inline at startup), one-time + chop materialization pass, then registration of all IPC handlers. Security posture is good at + the window level: `contextIsolation: true`, `nodeIntegration: false`, window-open handler only + allows `https:` via `shell.openExternal`, single-instance lock. +- **Preload** (`electron/preload/index.ts`): exposes a typed `window.api` bridge. The + contract (`electron/ipc-contract.ts`) is genuinely a single source of truth for + preload/main/renderer signatures — a strong point. It does **not** validate values, only types + at compile time; at runtime every channel trusts renderer-supplied paths, hashes, and numbers. +- **Renderer** (`src/`): React 19 + Zustand stores (`projects`, `library`, `packs`, `player`, + `stems`, `freesound`, `ui`, `toast`), three views (Chop / Library / Packs), WaveSurfer for the + chop editor, a DSP worker pool for BPM/key/transient/loop analysis, and a demucs WASM worker + for stem separation (loaded via `(0, eval)(jsText)` — the reason for the `'unsafe-eval'` CSP). + +### Real execution paths (traced) + +1. **Open → chop → autosave**: `Loader` sets `player.audio` → `AudioWaveform` mounts (keyed by + URL) → `useRegions` drives the WaveSurfer regions plugin → every region change bumps + `revision` → `useChopAutosave` debounces (1.5 s, max-wait 5 s) → + `projects.autosaveActiveRegions` → first save `projects:save` (creates project + chops, then + `syncProjectChopsToLibrary` renders **every chop through ffmpeg** before the IPC promise + resolves); later saves `projects:upsertChops` + the same sync. The library is a projection: + each chop becomes a real WAV under `userData/samples/` with `source='chop'` and + `source_chop_id` provenance. +2. **Trim**: `audio:trimSource` → `trimSourceToCache` renders a new WAV under `userData/sources/` + → renderer remaps regions to the new 0-based timeline (`remapRegionsForTrim`, dropping region + ids) → autosave persists → `setAudio` remounts the editor on the trimmed file. +3. **Pack**: drag a library sample onto a pad → `packs:upsertSlot` → `materializeSlotAudio` + renders a pad-owned WAV under `userData/pack-slots/` (fallback: null owned path, export trims + from source). Export: `packs:export` → `exportClips` renders every slot **in parallel** to the + picked folder using the hardware profile's format + filename convention. +4. **Freesound**: `freesound:search` (key read from `settings.json` per call) → + `freesound:download` fetches the **HQ preview MP3** (not the original) into + `userData/staging/.mp3` → loaded straight into the chop editor. +5. **Stems**: renderer fetches source bytes, SHA-256 hash → `stems:getCached` → else load + model bytes over IPC, eval in worker, separate, `stems:persist` writes 4 WAVs under + `userData/stems//` through the shared render funnel. +6. **Startup**: `materializeProjectChops()` is awaited **before** `createWindow()` — a one-time + backfill that runs one ffmpeg render per legacy chop. + +### Key invariants (as designed, per ADR titles + code comments) + +- Chops autosave; there is no explicit save (ADR-0004). +- The library is a materialized, live projection of project chops (ADR-0006); removing a chop + removes its library sample; packs are **not** touched by that. +- Packs are independent snapshots: pads own their trimmed audio (`audio_path`), so export must + never depend on the source chop/sample/file still existing (ADR-0003). +- Pad audition is preview-only (ADR-0005). + +Where those invariants are enforced vs. assumed is the core of the findings below: most are +enforced only by the happy-path call sequence, not by the data layer, and several break under +concurrency or deletion. + +--- + +## 2. Findings + +Severity scale: **P0** data loss / broken headline feature · **P1** correctness or UX failure +users will hit · **P2** debt, drift, leaks · **P3** minor. + +### 2.1 Correctness + +**F1 · P0 · Deleting a library sample deletes the user's original file on disk.** +`electron/main/ipc/library.ts:51-56` — `library:deleteSample` unconditionally +`fs.unlinkSync(filePath)` after removing the row. `library:importFolder` (`library.ts:40-49`) +registers files **in place** — `filePath` is the user's own file in their own folder; nothing is +copied into app storage. Scenario: user imports `~/Music/Breaks/` (500 files), later prunes a few +rows from the Library ("This sample will be permanently deleted from your library", says the +dialog — `src/views/Library/index.tsx:280-282`) → their original files are destroyed. The same +unlink also hits stems-cache WAVs added via "Save stem to Library" +(`AudioWaveform.tsx:516-528` stores `filePath` pointing into `userData/stems//`), silently +corrupting the stem cache contract that `getCachedStems` checks (all-4-present → now 3). +**CONFIRMED.** Direction: track file ownership per row (`owned: bool`, true only for files the +app rendered/downloaded into userData) and only unlink owned files; or copy-on-import. + +**F2 · P0 · 24-bit export profiles pass an invalid ffmpeg sample format — MPC and Generic WAV +exports fail.** `electron/main/hardware/profiles.ts:26,40` set `sampleFmt: 's24'`; +`electron/main/services/render.ts:41` passes it as `-sample_fmt s24`. ffmpeg has **no `s24` +sample format** (valid: `u8 s16 s32 flt dbl s64` + planar); `-sample_fmt` with an unparseable +value is a fatal option error, so every clip render for `mpc-generic` and `generic` rejects. +Combined with F7 (silent export failure) the user clicks Export on an MPC pack and gets an empty +folder with no error. The test suite (`render.test.ts`) only ever exercises `s16`, so CI cannot +catch this; the README advertises both profiles as supported hardware. **CONFIRMED** by ffmpeg +semantics (unrunnable in this sandbox — flagging honestly: if fluent-ffmpeg or a future ffmpeg +build aliased `s24`, this would downgrade, but no released ffmpeg does). Direction: use +`-c:a pcm_s24le` for 24-bit WAV (drop `-sample_fmt`), and add a render test per profile format. + +**F3 · P1 · Autosave race creates duplicate projects.** +`src/hooks/useChopAutosave.ts:52-71` + `src/stores/projects.ts:75-91` + +`electron/main/ipc/library.ts:95-99`. The first save is slow by design: `projects:save` awaits +`syncProjectChopsToLibrary`, i.e. one ffmpeg render per chop, before resolving. Meanwhile +`lastSavedAt` is still 0, so the next region edit computes `elapsed >= MAX_WAIT_MS` → fires a +second `autosaveActiveRegions` with **delay 0**; `activeProject` is still null (set only when +save #1 resolves) → a second `projects:save` → two projects (and two full library +materializations) for one session. Scenario: drop a file, drag out 8 chops quickly — very likely +on any non-trivial file. **CONFIRMED** by trace. Direction: an in-flight guard/promise latch in +`autosaveActiveRegions` (create-once semantics keyed by sourcePath), or make `projects:save` +return before materialization. + +**F4 · P1 · Concurrent library syncs double-materialize chops.** +`electron/main/services/materializeChops.ts:75-102` reads `existing` samples, then awaits ffmpeg +per chop before inserting. IPC handlers interleave on the main-process event loop, and three +renderer paths call sync-triggering channels independently (autosave `upsertChops`, Send-to-Pack's +own `autosaveActiveRegions`, trim's save): two overlapping syncs both see "no sample for chop X" +and both `addSample` → duplicate `source_chop_id` rows + duplicate WAVs. The dedup map +(`existingByChopId`, last-wins) never removes the extra row while the chop lives. **CONFIRMED** +by trace (no serialization anywhere in main). Direction: serialize per-project sync (simple +promise chain keyed by projectId) and/or a UNIQUE index on `samples(source_chop_id)`. + +**F5 · P1 · Deleting the last chop — and "Clear all" — are never persisted.** +`src/hooks/useChopAutosave.ts:46`: `if (!filePath || !regions?.length) return` — a transition to +zero regions cancels the pending timer (effect cleanup) and schedules nothing. Scenario: user +deletes their only chop (or confirms the "Clear all chops?" dialog, `SampleList.tsx:60-75`), +quits, reopens → all chops are back, library samples included. The confirmation dialog promises +"This will remove all chops from this audio file". **CONFIRMED** by trace. Direction: allow the +empty-regions save (only skip when `regions === undefined`, i.e. plugin not ready). + +**F6 · P1 · Export filename collisions silently overwrite pads.** +`profiles.ts:51-53` `sanitize()` maps to `[a-z0-9_-]` and lowercases; `mpc-generic` and `generic` +filenames are name-only (`profiles.ts:27,41`). Pads "Kick!" and "Kick?" → both `kick_.wav`; +`exportClips` (`export.ts:28-35`) renders all clips **in parallel to the same path** and reports +`filesWritten: clips.length`. User exports 16 pads, gets 14 files and a "16 files exported" toast. +Empty display names produce `.wav`. **CONFIRMED.** Direction: de-duplicate filenames (suffix +`_2`), always include the slot number, and count actual files written. + +**F7 · P1 · Pack export / send-to-pack failures are swallowed — no error UI.** +`src/views/Packs/index.tsx:58-69` `handleExport` is `try { … } finally { … }` with **no catch**: +a rejected `exportClips` (missing legacy source file, F2, unwritable folder) resets the button +and surfaces nothing but an unhandled rejection in the console. Same pattern: +`handleSendToPack` (`AudioWaveform.tsx:235-271`), `regenerateSlot`/`updateSlotFromSource` +(`Packs/index.tsx:153-175`), `handleImportFolder` (`AppSidebar.tsx:219-234`). Also +`exportClips`'s `Promise.all` aborts on first rejection while sibling renders keep writing — +partial exports are left in the output folder with no cleanup or report. **CONFIRMED.** +Direction: catch → toast in every user-triggered async action; per-clip `allSettled` with a +written/failed summary. + +**F8 · P1 · Stems: concurrent/cancelled separations hang or cross wires.** +`src/stores/stems.ts:41-51,148-153,195-199`: `pendingResult` and `onProgress` are module-level +singletons. A second `separate()` while one is in flight (select stem → restore → run again, or +switch source fast) clobbers `pendingResult` — the first `await` never settles (leaked promise, +status machine now lies). `cancel()` terminates the worker but never rejects `pendingResult`, so +the in-flight `separate()` also hangs forever at `stems.ts:148`; only the `set({status:'idle'})` +masks it. There is also no input-length guard: a 10-minute track is ~2×53 MB in, 8×53 MB out, +plus an 85 MB model in a 32-bit-heap Emscripten build — an OOM/abort path with no message. +**CONFIRMED** (hang paths traced); OOM PLAUSIBLE. Direction: per-run token + reject-on-cancel, +disable Run while running, cap duration with a clear toast. + +**F9 · P2 · `renderLibrarySample` records the *requested* duration, not the real one.** +`materializeChops.ts:34` returns `duration: end - start`. A chop whose end overshoots the decoded +source (drag to the edge, ffmpeg mp3 duration jitter) yields a shorter file with a longer claimed +duration — Library shows it, pad grid shows it, and pad audition's region-stop logic +(`useAudioPlayer.ts:25-31`) waits for a timestamp that never arrives. **CONFIRMED** (mismatch), +impact minor. Direction: probe the output (the WAV header is already being read for the waveform). + +**F10 · P2 · `extractWaveformData` mis-parses non-canonical WAVs.** +`electron/main/audio/waveform.ts:8-14`: the chunk walk ignores odd-size chunk padding (the test +helper `test/wav.ts:33` gets this right — two divergent WAV parsers in one repo), and if no +`data` chunk is found it silently treats trailing garbage as samples. Fine for ffmpeg-produced +files today; wrong the day someone points it at an arbitrary WAV (e.g. +`packs:regenerateSlotToLibrary` runs it on any `audioPath`). **CONFIRMED** (logic), low impact. + +**F11 · P2 · Freesound store swallows offline/API errors.** `src/stores/freesound.ts:43-82` — +`search`/`loadMore` let rejections escape `withLoading`; callers (`Loader.tsx:224-232`, +`setSort`, `setDurationFilter`) never catch. Offline or 401 (bad key) → spinner stops, empty +results, zero feedback. **CONFIRMED.** Direction: catch → toast, and detect 401 → "check your +API key". + +**F12 · P2 · `useChopAutosave` failure path lies.** `useChopAutosave.ts:70` — +`.catch(() => setSaveStatus('idle'))`: a failed save (DB error, ffmpeg missing) shows the same +idle state as success; edits are silently unsaved. The header can even show "Saved" from a prior +run. **CONFIRMED.** Direction: `saveStatus: 'error'` + retry. + +**F13 · P2 · Pad audition plays the live source, not the pad's snapshot.** +`Packs/index.tsx:508-510` — `useAudioPlayer(toLocalFileUrl(slot.sourcePath), region)`. The pad +*owns* `audio_path` precisely so it survives source deletion, but preview reads the original +file: delete/move the source and the pad still exports fine yet auditions as silence (and the +region-bounds stop uses `ontimeupdate`, overshooting by up to ~250 ms). ADR-0005's +"preview-only" promise is kept; the snapshot promise is not. **CONFIRMED.** Direction: prefer +`slot.audioPath` for audition. + +### 2.2 Alternative / unintended paths + +**F14 · P1 · Startup is blocked by the materialization backfill.** +`electron/main/index.ts:249-260` awaits `materializeProjectChops()` **before** `createWindow()`. +The comment says it "runs off the sync migration path", but it still gates the first window: a +user upgrading with 300 legacy chops waits for 300 sequential ffmpeg renders staring at nothing +(the splash is created in `createWindow`, which hasn't run). A missing source makes each chop +retry **on every launch** forever (catch-and-skip, `materializeChops.ts:63-66`). **CONFIRMED.** +Direction: create the window first, run the backfill after `ready-to-show`, and record permanent +failures. + +**F15 · P2 · Second-call / crash edges in slot upsert.** `packs.ts:47-55`: two rapid +`upsertSlot`s for the same pad both read `previous`, both materialize; the loser's freshly +rendered WAV is orphaned on disk forever (nothing references it, nothing deletes it). A crash +between `materializeSlotAudio` and the DB write leaks the same way. **CONFIRMED** (unbounded but +slow leak). Direction: write-then-diff inside a transaction, or a startup sweep of +`pack-slots/` against `pack_slots.audio_path`. + +**F16 · P2 · `library:importFolder` blocks the main process and dedups only by exact path.** +`library.ts:8-22,40-49`: synchronous recursive scan + N synchronous inserts on the UI/event-loop +thread — a big NAS folder freezes every window and all IPC. Re-importing the same file from a +renamed/moved folder duplicates rows (dedup is `Set` of exact `file_path`). **CONFIRMED.** +Direction: async scan, chunked inserts, content-hash or (dev,inode) dedup if desired. + +**F17 · P2 · Corrupted/locked DB and missing ffmpeg have no user-facing story.** `initDatabase` +throws → caught only by the global `uncaughtException` dialog (good), but WAL + a second +half-alive instance, or a corrupt file, yields a raw better-sqlite3 message with no recovery +hint. Missing ffmpeg binary (unsupported arch — `optionalDependencies` pins only darwin/win32; +Linux resolves transitively, `package.json:82-86`) surfaces as per-render rejections that F7 +then swallows entirely. **PLAUSIBLE** (not traced on a real broken install). Direction: probe +ffmpeg once at startup; preflight DB open with a "your library is damaged, backup at…" path. + +### 2.3 Incoherences (names that lie, duplicated truth, dead code) + +**F18 · P2 · The auto-update system is dead code — users never get updates.** +`electron/main/update.ts` registers `check-update` / `start-download` / `quit-and-install` and +sends `update-can-available` to the renderer, but the preload bridge exposes **none** of it +(`ipc-contract.ts` has no update surface) and with `contextIsolation` the renderer cannot invoke +those channels; nothing anywhere calls `checkForUpdates`. `electron-updater` ships in every build +(and `autoDownload=false` means even a stray check downloads nothing). Also `startDownload` +(`update.ts:68-76`) re-registers listeners per call — would multiply progress events if it were +ever wired. **CONFIRMED.** Direction: either expose it in the contract + UI, or delete the module +and the dependency; the current state quietly strands old versions (relevant given 0.0.x weekly +cadence). + +**F19 · P2 · Duplicated sources of truth.** (a) Hardware profiles exist twice: main +(`hardware/profiles.ts`) and a hardcoded copy in the renderer (`Packs/index.tsx:27-32`) while +the purpose-built `packs:getProfiles` channel has **zero callers**; the two disagree with +README's table ("Akai MPC (generic)" vs UI "Akai MPC One"). (b) `padCount` exists per profile +(128 for generic) but the grid, `filledSlots/16` label, recovery scan, and Send-to-Pack's +`.slice(0, 16)` (`AudioWaveform.tsx:247`) all hardcode 16. (c) `projects.regions` JSON column is +still written on every save (`projects.ts:66-74,98-109`) with **two different shapes** +(save omits `updatedAt`, update includes it) but is never read — `deserialize` reads +`project_chops`. (d) `bitDepth` in profile formats is dead; only `sampleFmt` matters (see F2). +(e) `pack_slots.pitch_shift_semitones` / `time_stretch_ratio` are created, migrated, seeded, and +always NULL — a feature that exists only in the schema. **CONFIRMED.** + +**F20 · P3 · Dead code inventory.** `src/components/Nav.tsx` and `Card/CardRoot.tsx` (no +importers), `audio:exportRegions` channel + `exportClips` region path (no renderer caller — +the "export chops directly" feature is gone but its IPC and types remain), `library:saveChops` + +`useLibraryStore.saveChops` (no caller), `packs.exportProgress` (never set — promises progress +that doesn't exist), `detectTransientsFromUrl` (worker `transients` kind unreachable from UI), +`convertBlobUrlToArrayBuffer`, `useShortcuts`'s empty Tab/Escape handlers. **CONFIRMED.** +Direction: delete; every dead channel is attack/maintenance surface. + +**F21 · P2 · "staging", "sources", "cache" are permanent directories pretending to be +temporary.** Freesound downloads land in `userData/staging/` and become the **canonical +long-term source** of any project chopped from them; `trimSourceToCache` (`trim.ts:7-18`) says +"cached" but every trim mints a new UUID WAV that is never reused nor deleted, and the old +trimmed source of a re-trimmed project is orphaned. Nothing ever cleans `staging/`, `sources/`, +`stems/`, or orphaned `pack-slots/` audio (F15) — and `deleteSample` (`samples.ts:126-132`) +deletes pack_slot **rows** without unlinking their `audio_path` files. Unbounded disk growth +with misleading names. **CONFIRMED.** Direction: rename honestly, add a startup GC that sweeps +files unreferenced by any project/sample/slot row. + +**F22 · P2 · Deletion semantics contradict the snapshot ADR.** `samples.deleteSample` +(`samples.ts:126-132`) removes `pack_slots` rows referencing the sample — the pad dies even +though it owns its audio (`audio_path` would keep export working) — while +`deleteChopSampleRow` (`samples.ts:153-158`) deliberately leaves slots alive for the same +situation, and `packs:regenerateSlotToLibrary` exists precisely to resurrect orphaned pads. The +UI warns ("Deleting it will remove those slots permanently"), so users aren't ambushed, but the +data layer implements two opposite philosophies for the same event. Note the migrated +`pack_slots` table has **no FK on sample_id** (`db/index.ts:164-216` recreates it without +REFERENCES), so keeping the slots is entirely feasible. **CONFIRMED.** Direction: pick one — +orphan the pad into the existing recovery flow instead of deleting it. + +**F23 · P3 · Misc incoherences.** `if (release().startsWith('6.1')) app.disableHardwareAcceleration()` +(`main/index.ts:99`) targets Windows 7 but also matches Linux kernel 6.1.x LTS. +`electron-builder.json5` declares Linux targets that no workflow builds and the README doesn't +mention. `handle('shell:openExternal', …)` is registered inline in `main/index.ts:245` instead of +an `ipc/` module. Seed slots are **1-based** while the pad grid is 0-based +(`seed.sh:156-167` vs `Packs/index.tsx:309-313`) — seeded pads render shifted one pad down, and a +seeded slot 16 would be invisible. **CONFIRMED.** + +### 2.4 Affordance mismatches + +**F24 · P1 · Freesound "Import to Library" neither imports to the library nor downloads the +sound.** The download button's tooltip is "Import to Library" (`Loader.tsx:400`), but +`handleDownload` only stages a file and opens it in Chop — nothing is added to the library unless +chops are later made. And `freesound:download` (`freesound.ts:36-44`) fetches +`previews['preview-hq-mp3']` — a lossy ~128 kbps **preview**, not the original file (originals +need OAuth2) — while the README sells "search 650,000+ Creative Commons sounds". No license or +attribution is stored anywhere (`FreesoundResult.license` is fetched, shown nowhere, persisted +nowhere) even though most CC licenses on Freesound require attribution; users exporting packs +have no way to comply. **CONFIRMED.** Direction: fix the tooltip, persist +license/author/freesound_id on the sample row, state the preview-quality limitation in UI+README. + +**F25 · P2 · Send to Pack silently truncates and mislabels.** `AudioWaveform.tsx:246-268` +slices to 16 chops with no warning when there are more, always creates a **new** pack (repeat +sends create "X Pack" clones), and stamps every slot with the source-level `bpm`/`musicalKey`. + +**F26 · P3 · UI promises macOS keys on Windows.** README and the Chop footer/tooltips show +`⌘Z`/`⇧⌘Z`/`⌘K` only; the handlers do accept Ctrl (`useShortcuts.ts:88`, +`CommandPalette.tsx:35`), so the *labels* are wrong on the shipped Windows build. + +### 2.5 Missing functionality + +**F27 · P1 · No cancellation or timeout for any long operation.** ffmpeg renders (per-chop sync, +pack export — 16 parallel spawns, or up to 128 for the generic profile), folder import, bulk +re-analyze, stem persist. A hung ffmpeg (corrupt input) leaves promises pending forever; the +only "cancel" in the app (stems) leaks its promise (F8). Direction: kill-on-timeout in +`renderClip`, concurrency cap in `exportClips`, AbortController plumbing. + +**F28 · P2 · No input validation in main for region math.** `start`/`end` are trusted +everywhere (`audio:trimSource`, `saveChops`, `upsertChops`, slot bounds): `end <= start`, +negative, NaN, or Infinity flow straight into `setDuration(end - start)` → ffmpeg fatal error → +generic rejection (often swallowed, F7). The renderer mostly prevents this; main assumes it. +Direction: clamp/validate at the IPC boundary — it's ~10 lines. + +**F29 · P2 · No observability.** `logMain` exists but only startup/fatal paths use it; every +`catch { /* ignore */ }` (12+ sites: unlinks, sync failures, materialization skips) is +invisible. A user reporting "my library lost samples" leaves nothing to inspect. Direction: +route swallowed errors through `logMain` at minimum. + +**F30 · P3 · Settings writes are not atomic** (`settings.ts:18-20`) — a crash mid-write +truncates `settings.json`; `read()` then silently returns `{}` and the Freesound key vanishes +with no message. Write-temp-then-rename is one line. + +### 2.6 Boundary & safety (Electron posture) + +Overall posture is decent for a local-first app: context isolation on, node integration off, +no remote content in the window, `openExternal` restricted to `https:`, model files whitelisted +(`stems.ts:9,15-22` — tested against traversal in `stems.test.ts:45-48`). Remaining real items: + +**F31 · P1 · `stems:persist` / `stems:getCached` path traversal via `sourceHash`.** +`stems.ts:24-30`: `path.join(userData, 'stems', sourceHash)` with a renderer-supplied string — +`'../../../../Users/x/.ssh'` escapes userData; `persistStems` then `mkdirSync`s and writes +attacker-shaped WAV bytes at the joined path (and `renderClip` output lands there too). Renderer +compromise is required, but this app's CSP deliberately allows `'unsafe-eval'` renderer-wide +(`main/index.ts:233` — `script-src 'self' 'unsafe-eval'`, not scoped to the worker), and the +renderer regularly renders strings from a remote API (Freesound names/tags — React escapes +them today). Defense-in-depth says validate: `/^[0-9a-f]{64}$/`. **CONFIRMED** (traversal is +real; exploitability gated on renderer compromise). Same class, lower stakes: +`freesound:download` fetches **any** renderer-supplied URL with Electron's net stack (SSRF / +arbitrary-content file write into staging), and `packs:export` / `audio:exportRegions` / +`library:importFolder` accept arbitrary absolute paths (write-anywhere / read-tree-anywhere +primitives). Direction: URL host allowlist (`freesound.org`, `cdn.freesound.org`); paths from +dialogs could be brokered by main instead of round-tripping through the renderer. + +**F32 · P2 · The `local-file://` protocol serves the entire filesystem to the renderer, CORS +open.** `main/index.ts:184-222`: any path, no scoping to userData or user-picked roots, plus +`Access-Control-Allow-Origin: *` and `bypassCSP: true`. That is the app's design (library rows +point at arbitrary user files), but combined with F31's threat model it means any renderer-side +script can read any file on disk via `fetch('local-file:///etc/passwd')`. Direction: maintain an +allowed-roots set (userData + imported folders), 403 otherwise. + +**F33 · P2 · Freesound API key handling is as documented but weak.** Plaintext in +`settings.json` (fine, documented in AGENTS.md), but it is re-read from disk on **every search** +(`freesound.ts:8-15`) and sent as a `token` query param (that's Freesound's API design). The +key never leaves the main process — good. Low risk; note only. + +**F34 · P2 · Windows path correctness across the URL bridge.** `toLocalFileUrl` +(`src/utils/index.ts:40-43`) splits on `/` only; a Windows path `C:\Users\me\track.mp3` becomes +`local-file://C%3A%5CUsers%5Cme%5Ctrack.mp3` whose "hostname" the protocol handler +(`main/index.ts:185-190`) reassembles as `/C:\Users\me\track.mp3` — `existsSync` on that form is +at best accidental. `fileNameFromPath` has the same `/`-only assumption. The app ships a Windows +NSIS build; if this breaks, **no local file plays or renders a waveform on Windows** — yet +nothing in CI runs the renderer at all. **PLAUSIBLE** (needs a Windows run; possibly masked if +Chromium normalizes back-slashes in URLs). Direction: normalize `\` → `/` in `toLocalFileUrl` +and add a Windows smoke test. + +### 2.7 Documentation & DX + +**F35 · P2 · README has no build-from-source/contributing section.** `pnpm install` → postinstall +`electron-rebuild` (needs toolchain) → `pnpm dev`; none of it is in the README (only download +links). AGENTS.md covers it implicitly, but that's agent-facing. The +`NODE_MODULE_VERSION` footgun **is** well documented in AGENTS.md (accurate: tests stub electron +and avoid the DB), which is genuinely good. + +**F36 · P2 · Seed script is macOS-only and destructive, and AGENTS.md bakes in the macOS +assumption.** `seed.sh:11-19` only knows `~/Library/Application Support/...`; on Linux +(`~/.config/samplebyte`) and Windows it exits "userData directory not found" even after +`pnpm dev`. AGENTS.md states "userData is always at ~/Library/Application Support/samplebyte/ in +both dev and production builds" — true only on macOS. The seed also `DELETE FROM samples` (the +user's whole dev library) without unlinking materialized WAVs (orphans), and its 1-based +`slot_number`s render shifted (F23). Docs claims vs reality otherwise check out: `pnpm dev`, +`pnpm test`, `pnpm release` flows match `package.json`/`tag.mjs`/workflows exactly, including +the fetch-stem-model-before-build requirement (present in `release.yml:22,40`). + +**F37 · P3 · CI gap: nothing builds or launches the app.** `ci.yml` runs tsc/lint/vitest/audit — +solid — but no `vite build`, no electron-builder dry-run, no renderer test at all (all tests are +main-process services). A broken renderer import ships to a tag before anyone notices; +`release.yml` would then publish it (draft → undrafted automatically). + +--- + +## 3. Design tensions (deepest structural issues) + +**T1 — Three lifetimes, one `file_path` column.** Library rows point at (a) user-owned files +imported in place, (b) app-rendered WAVs in `userData/samples`, (c) cache artifacts +(`stems/`, `staging/`). The schema cannot distinguish them, so every consumer guesses: +`deleteSample` unlinks all three (F1), the stems cache gets corrupted, "staging" becomes +permanent (F21). *Alternative:* an `owned` flag (or path-prefix rule) enforced in one +`deleteSampleFiles()` helper; or copy-on-import so everything under the library is app-owned — +simpler invariant, more disk. + +**T2 — Projection consistency by call-sequence, not by the data layer.** ADR-0006's "library is +a live projection" is implemented as "every writer remembers to call +`syncProjectChopsToLibrary` and no two calls overlap". Neither holds (F3, F4), and staleness is +decided by comparing wall-clock timestamps across tables (`isChopSampleStale`). *Alternative:* +serialize sync per project behind a queue in main, add UNIQUE(source_chop_id), and derive +staleness from a monotonic per-chop revision instead of `Date.now()` pairs. + +**T3 — Durable state lives in the waveform widget.** Chop identity is the WaveSurfer region id; +names live in a React state map keyed by those ids; a trim rebuilds regions **without ids** +(`remapRegionsForTrim` drops them) so every trim rewrites all chop rows, re-renders all library +WAVs, and orphans every pack-slot chop reference — maximal churn for a metadata-preserving +operation. Zero-region states are indistinguishable from "editor not ready" (F5). +*Alternative:* the DB row is the chop; the region is a view of it (id round-trips through +trim; empty is a valid saved state). + +**T4 — The hardware-profile abstraction is half real.** README: "adding a new hardware target is +just one config object". In reality a profile's `bitDepth` is ignored, its `sampleFmt` is passed +unvalidated into ffmpeg (F2), its `padCount` is ignored by the grid/UI (F19b), its filename +convention can self-collide (F6), and the renderer keeps its own profile list (F19a). A new +target added "as one config object" today would silently inherit all of this. *Alternative:* +make profiles the single source (serve via the existing dead `packs:getProfiles`), map +bit depth → codec in one place, test each profile's render. + +**T5 — Trusted-renderer IPC in an unsafe-eval renderer.** The bridge is beautifully typed but +validates nothing at runtime; simultaneously the CSP grants `'unsafe-eval'` to the whole +renderer for the sake of one worker, and `local-file://` serves the disk CORS-open (F31, F32). +Each choice is individually defensible; together they mean one renderer bug = full disk +read/write. *Alternative:* validate at the boundary (hash regex, URL allowlist, path roots) — +cheap — and scope eval to the worker (serve the worker script with its own CSP header, or ship +demucs as a real module). + +--- + +## 4. Expectation gaps ("I expected X, found Y") + +- Expected deleting a library entry to remove a row; found it deletes the user's original file + on disk (F1). +- Expected the advertised MPC/Generic 24-bit profiles to export; found an invalid ffmpeg flag + that fails every render (F2) — and a UI that reports failure as success (F7). +- Expected autosave to be crash-safe and idempotent; found duplicate projects under fast editing + (F3), duplicate samples under overlapping syncs (F4), and delete-all edits that never persist + (F5). +- Expected "packs are independent snapshots" to mean pads survive library deletion; found + deleteSample kills the pads (dialog does warn) while a sibling code path keeps them (F22), and + pad audition depends on the live source anyway (F13). +- Expected the auto-updater to update; found an unreachable IPC surface — no user ever gets an + update prompt (F18). +- Expected "Import to Library" on Freesound to import the sound; found it stages a 128 kbps + preview and opens the editor, storing no license/attribution (F24). +- Expected `userData/sources`, `staging` and "cache" to be reclaimable; found they are permanent, + growing, and load-bearing (F21). +- Expected the typed IPC contract to imply validated inputs; found path traversal in + `stems:persist` and fetch-any-URL in `freesound:download` (F31). +- Expected `pnpm seed` (per AGENTS.md) to work after `pnpm dev`; found it is macOS-only and + wipes the dev library (F36). +- Expected the test suite ("audio-rendering modules covered against real ffmpeg") to cover the + shipped profiles; found only `s16` is ever rendered (F2, F37). + +--- + +## 5. Open questions + +1. Is in-place import (no copy) a deliberate product decision? It drives F1/T1; a one-line answer + changes the right fix. +2. Is the Freesound preview-only download understood/accepted (vs. OAuth2 for originals), and is + license attribution intentionally out of scope for exported packs? +3. Was the auto-update UI removed deliberately (0.0.x cadence via manual downloads), or lost in + the refactor? `electron-updater` is still shipped either way. +4. Windows: has anyone run the packaged build end-to-end? (F34 would be immediately visible; the + README documents the SmartScreen flow, which suggests Windows is a real target.) +5. `pitch_shift_semitones` / `time_stretch_ratio` — roadmap items or abandoned? They shape the + slot schema and seed script today. +6. Generic profile `padCount: 128` — is >16-pad export intended soon? It determines whether the + hardcoded 16s (grid, send-to-pack slice, recovery scan) are bugs or ceiling. +7. The `.harness/` ADRs are encrypted in-repo; should QA artifacts like this one be committed + through the doctier filter (this environment has no filter configured, so this file is stored + as written)? + +--- + +## Appendix — strengths worth keeping + +Not everything is adversarial: the typed IPC contract (`ipc-contract.ts`) with compile-time +channel/signature agreement is excellent; the render funnel (`renderClip` as the single ffmpeg +choke point) is the right shape (it just needs profile validation); the pad-owned-audio snapshot +model is a genuinely good design (enforce it in deletion and audition paths); virtualized +library/source lists, the analysis worker pool with transfer semantics, the electron test stub +approach, and the honest, current AGENTS.md are all above average for a solo side project. From 7e556da2ce9bf9a91f9a6d781ef3242a3ee038c6 Mon Sep 17 00:00:00 2001 From: RubenGlez Date: Fri, 3 Jul 2026 20:40:31 +0200 Subject: [PATCH 2/3] fix: address adversarial codebase audit findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the P0–P3 findings from the 2026-07-03 adversarial audit (F1–F37) except F24's OAuth2 originals download, deferred to a follow-up. Data & lifetimes: - F1/T1: track file ownership; deleteSample only unlinks app-owned files - F9/F10: probe real WAV duration; robust chunk parser (padding, no-data) - F21/F15: startup GC sweep of orphaned files under managed dirs - F22: deleting a library sample orphans pads into recovery, not deletion - F19c/e: stop writing dead regions JSON / pitch/stretch columns Autosave & projection: - F3: create-once latch prevents duplicate projects under fast edits - F4: serialize per-project sync + UNIQUE(source_chop_id) - F5: persist empty-regions (delete-last-chop / clear-all) - F12: real 'error' save status Export/render: - F2: 24-bit via pcm_s24le (was invalid -sample_fmt s24) + per-profile test - F6/F7: dedup filenames, allSettled, count actual writes, catch→toast - F27/F28: ffmpeg timeout+kill, export concurrency cap, region validation Stems & security: - F8: per-run token, reject-on-cancel, duration cap - F31: hash regex for stem paths; Freesound download host allowlist - F32: local-file:// access gated to userData/DB-known/brokered paths Startup/import, updates, coherence, observability, docs/CI: - F14/F16/F17: window-first backfill, async import, ffmpeg+DB preflight - F18: auto-update wired end-to-end (contract, events, UpdateBanner) - F13/F19/F20/F23/F26: pad audition uses owned audio; single profile source; 16-pad ceiling; dead code removed; platform-correct key labels - F29/F30: shared logger for swallowed errors; atomic settings write - F34/F35/F36/F37: Windows path bridge; README contributing; cross-platform non-destructive seed with 0-based slots; renderer build in CI - F11/F33/F24(partial): Freesound error toasts; persist license/author/ freesound_id, show CC attribution, write credits.txt on export Claude-Session: https://claude.ai/code/session_01Jk8vYAovj8RiPQSnEBnzGC --- .github/workflows/ci.yml | 3 + AGENTS.md | 2 +- README.md | 23 ++++- electron/ipc-contract.ts | 38 ++++++-- electron/main/audio/waveform.test.ts | 50 +++++++++++ electron/main/audio/waveform.ts | 55 +++++++++--- electron/main/db/index.ts | 36 ++++++++ electron/main/db/queries/packs.ts | 8 +- electron/main/db/queries/projects.ts | 48 ++++++---- electron/main/db/queries/samples.ts | 50 +++++++++-- electron/main/hardware/profiles.ts | 17 ++-- electron/main/index.ts | 77 ++++++++++++++-- electron/main/ipc/audio.ts | 19 +--- electron/main/ipc/filesystem.ts | 12 ++- electron/main/ipc/freesound.ts | 17 ++++ electron/main/ipc/library.ts | 58 +++--------- electron/main/ipc/packs.ts | 45 +++++++++- electron/main/ipc/settings.ts | 17 +++- electron/main/services/export.ts | 66 +++++++++++--- electron/main/services/ffmpeg.ts | 13 +++ electron/main/services/gc.ts | 54 +++++++++++ electron/main/services/localFileAccess.ts | 47 ++++++++++ electron/main/services/log.ts | 33 +++++++ electron/main/services/materializeChops.ts | 56 +++++++++--- electron/main/services/render.test.ts | 26 ++++++ electron/main/services/render.ts | 51 +++++++++-- electron/main/services/stems.test.ts | 19 +++- electron/main/services/stems.ts | 8 ++ electron/main/update.ts | 100 +++++++++------------ electron/preload/index.ts | 29 +++++- electron/types.ts | 30 +++++-- scripts/seed.sh | 82 +++++++++-------- src/App.tsx | 10 ++- src/components/AppSidebar.tsx | 2 + src/components/AudioWaveform.tsx | 27 ++++-- src/components/Card/CardRoot.tsx | 16 ---- src/components/Loader.tsx | 15 ++-- src/components/Nav.tsx | 54 ----------- src/components/Toolbar.tsx | 5 +- src/components/UpdateBanner.tsx | 61 +++++++++++++ src/hooks/useChopAutosave.ts | 26 ++++-- src/hooks/useShortcuts.ts | 8 -- src/lib/audioAnalysis.ts | 8 -- src/stores/freesound.ts | 36 ++++++-- src/stores/library.ts | 10 +-- src/stores/packs.ts | 18 ++-- src/stores/player.ts | 2 + src/stores/projects.ts | 49 +++++++--- src/stores/stems.ts | 36 +++++++- src/types/global.d.ts | 4 +- src/types/index.ts | 1 - src/utils/index.ts | 42 ++++----- src/views/Library/index.tsx | 14 ++- src/views/Packs/index.tsx | 57 +++++++----- 54 files changed, 1217 insertions(+), 473 deletions(-) create mode 100644 electron/main/audio/waveform.test.ts create mode 100644 electron/main/services/gc.ts create mode 100644 electron/main/services/localFileAccess.ts create mode 100644 electron/main/services/log.ts delete mode 100644 src/components/Card/CardRoot.tsx delete mode 100644 src/components/Nav.tsx create mode 100644 src/components/UpdateBanner.tsx 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/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') From 8b78ae0157cb1e524555093d60cfefdc27427da5 Mon Sep 17 00:00:00 2001 From: RubenGlez Date: Fri, 3 Jul 2026 20:47:47 +0200 Subject: [PATCH 3/3] chore: encrypt adversarial audit doc via doctier The audit doc was committed as cleartext before the doctier clean filter was active; re-encrypt it so it satisfies the .harness private-doc policy. Claude-Session: https://claude.ai/code/session_01Jk8vYAovj8RiPQSnEBnzGC --- .harness/qa/adversarial-audit-2026-07-03.md | 1244 +++++++++++-------- 1 file changed, 744 insertions(+), 500 deletions(-) diff --git a/.harness/qa/adversarial-audit-2026-07-03.md b/.harness/qa/adversarial-audit-2026-07-03.md index b4c54da..ff0037a 100644 --- a/.harness/qa/adversarial-audit-2026-07-03.md +++ b/.harness/qa/adversarial-audit-2026-07-03.md @@ -1,500 +1,744 @@ -# SampleByte — Adversarial Codebase Audit (2026-07-03) - -Auditor: senior-staff-engineer pass over the full repository (every source file under -`electron/`, `src/`, `scripts/`, `test/`, `.github/`, plus configs and docs). No loyalty to the -current design. Findings are marked **CONFIRMED** (traced end-to-end in code, or provable from -tool semantics) or **PLAUSIBLE** (strong reasoning, needs a live run to prove — this environment -has no node_modules/ffmpeg/display, and the `.harness/` ADRs are age-encrypted, so ADR references -below are by title only). - ---- - -## 1. System map - -### Processes and boundaries - -- **Main process** (`electron/main/index.ts`): window + splash, custom `local-file://` protocol - (CORS-open file server for the renderer), production CSP injection, DB init - (`electron/main/db/index.ts`, better-sqlite3, WAL, migrations run inline at startup), one-time - chop materialization pass, then registration of all IPC handlers. Security posture is good at - the window level: `contextIsolation: true`, `nodeIntegration: false`, window-open handler only - allows `https:` via `shell.openExternal`, single-instance lock. -- **Preload** (`electron/preload/index.ts`): exposes a typed `window.api` bridge. The - contract (`electron/ipc-contract.ts`) is genuinely a single source of truth for - preload/main/renderer signatures — a strong point. It does **not** validate values, only types - at compile time; at runtime every channel trusts renderer-supplied paths, hashes, and numbers. -- **Renderer** (`src/`): React 19 + Zustand stores (`projects`, `library`, `packs`, `player`, - `stems`, `freesound`, `ui`, `toast`), three views (Chop / Library / Packs), WaveSurfer for the - chop editor, a DSP worker pool for BPM/key/transient/loop analysis, and a demucs WASM worker - for stem separation (loaded via `(0, eval)(jsText)` — the reason for the `'unsafe-eval'` CSP). - -### Real execution paths (traced) - -1. **Open → chop → autosave**: `Loader` sets `player.audio` → `AudioWaveform` mounts (keyed by - URL) → `useRegions` drives the WaveSurfer regions plugin → every region change bumps - `revision` → `useChopAutosave` debounces (1.5 s, max-wait 5 s) → - `projects.autosaveActiveRegions` → first save `projects:save` (creates project + chops, then - `syncProjectChopsToLibrary` renders **every chop through ffmpeg** before the IPC promise - resolves); later saves `projects:upsertChops` + the same sync. The library is a projection: - each chop becomes a real WAV under `userData/samples/` with `source='chop'` and - `source_chop_id` provenance. -2. **Trim**: `audio:trimSource` → `trimSourceToCache` renders a new WAV under `userData/sources/` - → renderer remaps regions to the new 0-based timeline (`remapRegionsForTrim`, dropping region - ids) → autosave persists → `setAudio` remounts the editor on the trimmed file. -3. **Pack**: drag a library sample onto a pad → `packs:upsertSlot` → `materializeSlotAudio` - renders a pad-owned WAV under `userData/pack-slots/` (fallback: null owned path, export trims - from source). Export: `packs:export` → `exportClips` renders every slot **in parallel** to the - picked folder using the hardware profile's format + filename convention. -4. **Freesound**: `freesound:search` (key read from `settings.json` per call) → - `freesound:download` fetches the **HQ preview MP3** (not the original) into - `userData/staging/.mp3` → loaded straight into the chop editor. -5. **Stems**: renderer fetches source bytes, SHA-256 hash → `stems:getCached` → else load - model bytes over IPC, eval in worker, separate, `stems:persist` writes 4 WAVs under - `userData/stems//` through the shared render funnel. -6. **Startup**: `materializeProjectChops()` is awaited **before** `createWindow()` — a one-time - backfill that runs one ffmpeg render per legacy chop. - -### Key invariants (as designed, per ADR titles + code comments) - -- Chops autosave; there is no explicit save (ADR-0004). -- The library is a materialized, live projection of project chops (ADR-0006); removing a chop - removes its library sample; packs are **not** touched by that. -- Packs are independent snapshots: pads own their trimmed audio (`audio_path`), so export must - never depend on the source chop/sample/file still existing (ADR-0003). -- Pad audition is preview-only (ADR-0005). - -Where those invariants are enforced vs. assumed is the core of the findings below: most are -enforced only by the happy-path call sequence, not by the data layer, and several break under -concurrency or deletion. - ---- - -## 2. Findings - -Severity scale: **P0** data loss / broken headline feature · **P1** correctness or UX failure -users will hit · **P2** debt, drift, leaks · **P3** minor. - -### 2.1 Correctness - -**F1 · P0 · Deleting a library sample deletes the user's original file on disk.** -`electron/main/ipc/library.ts:51-56` — `library:deleteSample` unconditionally -`fs.unlinkSync(filePath)` after removing the row. `library:importFolder` (`library.ts:40-49`) -registers files **in place** — `filePath` is the user's own file in their own folder; nothing is -copied into app storage. Scenario: user imports `~/Music/Breaks/` (500 files), later prunes a few -rows from the Library ("This sample will be permanently deleted from your library", says the -dialog — `src/views/Library/index.tsx:280-282`) → their original files are destroyed. The same -unlink also hits stems-cache WAVs added via "Save stem to Library" -(`AudioWaveform.tsx:516-528` stores `filePath` pointing into `userData/stems//`), silently -corrupting the stem cache contract that `getCachedStems` checks (all-4-present → now 3). -**CONFIRMED.** Direction: track file ownership per row (`owned: bool`, true only for files the -app rendered/downloaded into userData) and only unlink owned files; or copy-on-import. - -**F2 · P0 · 24-bit export profiles pass an invalid ffmpeg sample format — MPC and Generic WAV -exports fail.** `electron/main/hardware/profiles.ts:26,40` set `sampleFmt: 's24'`; -`electron/main/services/render.ts:41` passes it as `-sample_fmt s24`. ffmpeg has **no `s24` -sample format** (valid: `u8 s16 s32 flt dbl s64` + planar); `-sample_fmt` with an unparseable -value is a fatal option error, so every clip render for `mpc-generic` and `generic` rejects. -Combined with F7 (silent export failure) the user clicks Export on an MPC pack and gets an empty -folder with no error. The test suite (`render.test.ts`) only ever exercises `s16`, so CI cannot -catch this; the README advertises both profiles as supported hardware. **CONFIRMED** by ffmpeg -semantics (unrunnable in this sandbox — flagging honestly: if fluent-ffmpeg or a future ffmpeg -build aliased `s24`, this would downgrade, but no released ffmpeg does). Direction: use -`-c:a pcm_s24le` for 24-bit WAV (drop `-sample_fmt`), and add a render test per profile format. - -**F3 · P1 · Autosave race creates duplicate projects.** -`src/hooks/useChopAutosave.ts:52-71` + `src/stores/projects.ts:75-91` + -`electron/main/ipc/library.ts:95-99`. The first save is slow by design: `projects:save` awaits -`syncProjectChopsToLibrary`, i.e. one ffmpeg render per chop, before resolving. Meanwhile -`lastSavedAt` is still 0, so the next region edit computes `elapsed >= MAX_WAIT_MS` → fires a -second `autosaveActiveRegions` with **delay 0**; `activeProject` is still null (set only when -save #1 resolves) → a second `projects:save` → two projects (and two full library -materializations) for one session. Scenario: drop a file, drag out 8 chops quickly — very likely -on any non-trivial file. **CONFIRMED** by trace. Direction: an in-flight guard/promise latch in -`autosaveActiveRegions` (create-once semantics keyed by sourcePath), or make `projects:save` -return before materialization. - -**F4 · P1 · Concurrent library syncs double-materialize chops.** -`electron/main/services/materializeChops.ts:75-102` reads `existing` samples, then awaits ffmpeg -per chop before inserting. IPC handlers interleave on the main-process event loop, and three -renderer paths call sync-triggering channels independently (autosave `upsertChops`, Send-to-Pack's -own `autosaveActiveRegions`, trim's save): two overlapping syncs both see "no sample for chop X" -and both `addSample` → duplicate `source_chop_id` rows + duplicate WAVs. The dedup map -(`existingByChopId`, last-wins) never removes the extra row while the chop lives. **CONFIRMED** -by trace (no serialization anywhere in main). Direction: serialize per-project sync (simple -promise chain keyed by projectId) and/or a UNIQUE index on `samples(source_chop_id)`. - -**F5 · P1 · Deleting the last chop — and "Clear all" — are never persisted.** -`src/hooks/useChopAutosave.ts:46`: `if (!filePath || !regions?.length) return` — a transition to -zero regions cancels the pending timer (effect cleanup) and schedules nothing. Scenario: user -deletes their only chop (or confirms the "Clear all chops?" dialog, `SampleList.tsx:60-75`), -quits, reopens → all chops are back, library samples included. The confirmation dialog promises -"This will remove all chops from this audio file". **CONFIRMED** by trace. Direction: allow the -empty-regions save (only skip when `regions === undefined`, i.e. plugin not ready). - -**F6 · P1 · Export filename collisions silently overwrite pads.** -`profiles.ts:51-53` `sanitize()` maps to `[a-z0-9_-]` and lowercases; `mpc-generic` and `generic` -filenames are name-only (`profiles.ts:27,41`). Pads "Kick!" and "Kick?" → both `kick_.wav`; -`exportClips` (`export.ts:28-35`) renders all clips **in parallel to the same path** and reports -`filesWritten: clips.length`. User exports 16 pads, gets 14 files and a "16 files exported" toast. -Empty display names produce `.wav`. **CONFIRMED.** Direction: de-duplicate filenames (suffix -`_2`), always include the slot number, and count actual files written. - -**F7 · P1 · Pack export / send-to-pack failures are swallowed — no error UI.** -`src/views/Packs/index.tsx:58-69` `handleExport` is `try { … } finally { … }` with **no catch**: -a rejected `exportClips` (missing legacy source file, F2, unwritable folder) resets the button -and surfaces nothing but an unhandled rejection in the console. Same pattern: -`handleSendToPack` (`AudioWaveform.tsx:235-271`), `regenerateSlot`/`updateSlotFromSource` -(`Packs/index.tsx:153-175`), `handleImportFolder` (`AppSidebar.tsx:219-234`). Also -`exportClips`'s `Promise.all` aborts on first rejection while sibling renders keep writing — -partial exports are left in the output folder with no cleanup or report. **CONFIRMED.** -Direction: catch → toast in every user-triggered async action; per-clip `allSettled` with a -written/failed summary. - -**F8 · P1 · Stems: concurrent/cancelled separations hang or cross wires.** -`src/stores/stems.ts:41-51,148-153,195-199`: `pendingResult` and `onProgress` are module-level -singletons. A second `separate()` while one is in flight (select stem → restore → run again, or -switch source fast) clobbers `pendingResult` — the first `await` never settles (leaked promise, -status machine now lies). `cancel()` terminates the worker but never rejects `pendingResult`, so -the in-flight `separate()` also hangs forever at `stems.ts:148`; only the `set({status:'idle'})` -masks it. There is also no input-length guard: a 10-minute track is ~2×53 MB in, 8×53 MB out, -plus an 85 MB model in a 32-bit-heap Emscripten build — an OOM/abort path with no message. -**CONFIRMED** (hang paths traced); OOM PLAUSIBLE. Direction: per-run token + reject-on-cancel, -disable Run while running, cap duration with a clear toast. - -**F9 · P2 · `renderLibrarySample` records the *requested* duration, not the real one.** -`materializeChops.ts:34` returns `duration: end - start`. A chop whose end overshoots the decoded -source (drag to the edge, ffmpeg mp3 duration jitter) yields a shorter file with a longer claimed -duration — Library shows it, pad grid shows it, and pad audition's region-stop logic -(`useAudioPlayer.ts:25-31`) waits for a timestamp that never arrives. **CONFIRMED** (mismatch), -impact minor. Direction: probe the output (the WAV header is already being read for the waveform). - -**F10 · P2 · `extractWaveformData` mis-parses non-canonical WAVs.** -`electron/main/audio/waveform.ts:8-14`: the chunk walk ignores odd-size chunk padding (the test -helper `test/wav.ts:33` gets this right — two divergent WAV parsers in one repo), and if no -`data` chunk is found it silently treats trailing garbage as samples. Fine for ffmpeg-produced -files today; wrong the day someone points it at an arbitrary WAV (e.g. -`packs:regenerateSlotToLibrary` runs it on any `audioPath`). **CONFIRMED** (logic), low impact. - -**F11 · P2 · Freesound store swallows offline/API errors.** `src/stores/freesound.ts:43-82` — -`search`/`loadMore` let rejections escape `withLoading`; callers (`Loader.tsx:224-232`, -`setSort`, `setDurationFilter`) never catch. Offline or 401 (bad key) → spinner stops, empty -results, zero feedback. **CONFIRMED.** Direction: catch → toast, and detect 401 → "check your -API key". - -**F12 · P2 · `useChopAutosave` failure path lies.** `useChopAutosave.ts:70` — -`.catch(() => setSaveStatus('idle'))`: a failed save (DB error, ffmpeg missing) shows the same -idle state as success; edits are silently unsaved. The header can even show "Saved" from a prior -run. **CONFIRMED.** Direction: `saveStatus: 'error'` + retry. - -**F13 · P2 · Pad audition plays the live source, not the pad's snapshot.** -`Packs/index.tsx:508-510` — `useAudioPlayer(toLocalFileUrl(slot.sourcePath), region)`. The pad -*owns* `audio_path` precisely so it survives source deletion, but preview reads the original -file: delete/move the source and the pad still exports fine yet auditions as silence (and the -region-bounds stop uses `ontimeupdate`, overshooting by up to ~250 ms). ADR-0005's -"preview-only" promise is kept; the snapshot promise is not. **CONFIRMED.** Direction: prefer -`slot.audioPath` for audition. - -### 2.2 Alternative / unintended paths - -**F14 · P1 · Startup is blocked by the materialization backfill.** -`electron/main/index.ts:249-260` awaits `materializeProjectChops()` **before** `createWindow()`. -The comment says it "runs off the sync migration path", but it still gates the first window: a -user upgrading with 300 legacy chops waits for 300 sequential ffmpeg renders staring at nothing -(the splash is created in `createWindow`, which hasn't run). A missing source makes each chop -retry **on every launch** forever (catch-and-skip, `materializeChops.ts:63-66`). **CONFIRMED.** -Direction: create the window first, run the backfill after `ready-to-show`, and record permanent -failures. - -**F15 · P2 · Second-call / crash edges in slot upsert.** `packs.ts:47-55`: two rapid -`upsertSlot`s for the same pad both read `previous`, both materialize; the loser's freshly -rendered WAV is orphaned on disk forever (nothing references it, nothing deletes it). A crash -between `materializeSlotAudio` and the DB write leaks the same way. **CONFIRMED** (unbounded but -slow leak). Direction: write-then-diff inside a transaction, or a startup sweep of -`pack-slots/` against `pack_slots.audio_path`. - -**F16 · P2 · `library:importFolder` blocks the main process and dedups only by exact path.** -`library.ts:8-22,40-49`: synchronous recursive scan + N synchronous inserts on the UI/event-loop -thread — a big NAS folder freezes every window and all IPC. Re-importing the same file from a -renamed/moved folder duplicates rows (dedup is `Set` of exact `file_path`). **CONFIRMED.** -Direction: async scan, chunked inserts, content-hash or (dev,inode) dedup if desired. - -**F17 · P2 · Corrupted/locked DB and missing ffmpeg have no user-facing story.** `initDatabase` -throws → caught only by the global `uncaughtException` dialog (good), but WAL + a second -half-alive instance, or a corrupt file, yields a raw better-sqlite3 message with no recovery -hint. Missing ffmpeg binary (unsupported arch — `optionalDependencies` pins only darwin/win32; -Linux resolves transitively, `package.json:82-86`) surfaces as per-render rejections that F7 -then swallows entirely. **PLAUSIBLE** (not traced on a real broken install). Direction: probe -ffmpeg once at startup; preflight DB open with a "your library is damaged, backup at…" path. - -### 2.3 Incoherences (names that lie, duplicated truth, dead code) - -**F18 · P2 · The auto-update system is dead code — users never get updates.** -`electron/main/update.ts` registers `check-update` / `start-download` / `quit-and-install` and -sends `update-can-available` to the renderer, but the preload bridge exposes **none** of it -(`ipc-contract.ts` has no update surface) and with `contextIsolation` the renderer cannot invoke -those channels; nothing anywhere calls `checkForUpdates`. `electron-updater` ships in every build -(and `autoDownload=false` means even a stray check downloads nothing). Also `startDownload` -(`update.ts:68-76`) re-registers listeners per call — would multiply progress events if it were -ever wired. **CONFIRMED.** Direction: either expose it in the contract + UI, or delete the module -and the dependency; the current state quietly strands old versions (relevant given 0.0.x weekly -cadence). - -**F19 · P2 · Duplicated sources of truth.** (a) Hardware profiles exist twice: main -(`hardware/profiles.ts`) and a hardcoded copy in the renderer (`Packs/index.tsx:27-32`) while -the purpose-built `packs:getProfiles` channel has **zero callers**; the two disagree with -README's table ("Akai MPC (generic)" vs UI "Akai MPC One"). (b) `padCount` exists per profile -(128 for generic) but the grid, `filledSlots/16` label, recovery scan, and Send-to-Pack's -`.slice(0, 16)` (`AudioWaveform.tsx:247`) all hardcode 16. (c) `projects.regions` JSON column is -still written on every save (`projects.ts:66-74,98-109`) with **two different shapes** -(save omits `updatedAt`, update includes it) but is never read — `deserialize` reads -`project_chops`. (d) `bitDepth` in profile formats is dead; only `sampleFmt` matters (see F2). -(e) `pack_slots.pitch_shift_semitones` / `time_stretch_ratio` are created, migrated, seeded, and -always NULL — a feature that exists only in the schema. **CONFIRMED.** - -**F20 · P3 · Dead code inventory.** `src/components/Nav.tsx` and `Card/CardRoot.tsx` (no -importers), `audio:exportRegions` channel + `exportClips` region path (no renderer caller — -the "export chops directly" feature is gone but its IPC and types remain), `library:saveChops` + -`useLibraryStore.saveChops` (no caller), `packs.exportProgress` (never set — promises progress -that doesn't exist), `detectTransientsFromUrl` (worker `transients` kind unreachable from UI), -`convertBlobUrlToArrayBuffer`, `useShortcuts`'s empty Tab/Escape handlers. **CONFIRMED.** -Direction: delete; every dead channel is attack/maintenance surface. - -**F21 · P2 · "staging", "sources", "cache" are permanent directories pretending to be -temporary.** Freesound downloads land in `userData/staging/` and become the **canonical -long-term source** of any project chopped from them; `trimSourceToCache` (`trim.ts:7-18`) says -"cached" but every trim mints a new UUID WAV that is never reused nor deleted, and the old -trimmed source of a re-trimmed project is orphaned. Nothing ever cleans `staging/`, `sources/`, -`stems/`, or orphaned `pack-slots/` audio (F15) — and `deleteSample` (`samples.ts:126-132`) -deletes pack_slot **rows** without unlinking their `audio_path` files. Unbounded disk growth -with misleading names. **CONFIRMED.** Direction: rename honestly, add a startup GC that sweeps -files unreferenced by any project/sample/slot row. - -**F22 · P2 · Deletion semantics contradict the snapshot ADR.** `samples.deleteSample` -(`samples.ts:126-132`) removes `pack_slots` rows referencing the sample — the pad dies even -though it owns its audio (`audio_path` would keep export working) — while -`deleteChopSampleRow` (`samples.ts:153-158`) deliberately leaves slots alive for the same -situation, and `packs:regenerateSlotToLibrary` exists precisely to resurrect orphaned pads. The -UI warns ("Deleting it will remove those slots permanently"), so users aren't ambushed, but the -data layer implements two opposite philosophies for the same event. Note the migrated -`pack_slots` table has **no FK on sample_id** (`db/index.ts:164-216` recreates it without -REFERENCES), so keeping the slots is entirely feasible. **CONFIRMED.** Direction: pick one — -orphan the pad into the existing recovery flow instead of deleting it. - -**F23 · P3 · Misc incoherences.** `if (release().startsWith('6.1')) app.disableHardwareAcceleration()` -(`main/index.ts:99`) targets Windows 7 but also matches Linux kernel 6.1.x LTS. -`electron-builder.json5` declares Linux targets that no workflow builds and the README doesn't -mention. `handle('shell:openExternal', …)` is registered inline in `main/index.ts:245` instead of -an `ipc/` module. Seed slots are **1-based** while the pad grid is 0-based -(`seed.sh:156-167` vs `Packs/index.tsx:309-313`) — seeded pads render shifted one pad down, and a -seeded slot 16 would be invisible. **CONFIRMED.** - -### 2.4 Affordance mismatches - -**F24 · P1 · Freesound "Import to Library" neither imports to the library nor downloads the -sound.** The download button's tooltip is "Import to Library" (`Loader.tsx:400`), but -`handleDownload` only stages a file and opens it in Chop — nothing is added to the library unless -chops are later made. And `freesound:download` (`freesound.ts:36-44`) fetches -`previews['preview-hq-mp3']` — a lossy ~128 kbps **preview**, not the original file (originals -need OAuth2) — while the README sells "search 650,000+ Creative Commons sounds". No license or -attribution is stored anywhere (`FreesoundResult.license` is fetched, shown nowhere, persisted -nowhere) even though most CC licenses on Freesound require attribution; users exporting packs -have no way to comply. **CONFIRMED.** Direction: fix the tooltip, persist -license/author/freesound_id on the sample row, state the preview-quality limitation in UI+README. - -**F25 · P2 · Send to Pack silently truncates and mislabels.** `AudioWaveform.tsx:246-268` -slices to 16 chops with no warning when there are more, always creates a **new** pack (repeat -sends create "X Pack" clones), and stamps every slot with the source-level `bpm`/`musicalKey`. - -**F26 · P3 · UI promises macOS keys on Windows.** README and the Chop footer/tooltips show -`⌘Z`/`⇧⌘Z`/`⌘K` only; the handlers do accept Ctrl (`useShortcuts.ts:88`, -`CommandPalette.tsx:35`), so the *labels* are wrong on the shipped Windows build. - -### 2.5 Missing functionality - -**F27 · P1 · No cancellation or timeout for any long operation.** ffmpeg renders (per-chop sync, -pack export — 16 parallel spawns, or up to 128 for the generic profile), folder import, bulk -re-analyze, stem persist. A hung ffmpeg (corrupt input) leaves promises pending forever; the -only "cancel" in the app (stems) leaks its promise (F8). Direction: kill-on-timeout in -`renderClip`, concurrency cap in `exportClips`, AbortController plumbing. - -**F28 · P2 · No input validation in main for region math.** `start`/`end` are trusted -everywhere (`audio:trimSource`, `saveChops`, `upsertChops`, slot bounds): `end <= start`, -negative, NaN, or Infinity flow straight into `setDuration(end - start)` → ffmpeg fatal error → -generic rejection (often swallowed, F7). The renderer mostly prevents this; main assumes it. -Direction: clamp/validate at the IPC boundary — it's ~10 lines. - -**F29 · P2 · No observability.** `logMain` exists but only startup/fatal paths use it; every -`catch { /* ignore */ }` (12+ sites: unlinks, sync failures, materialization skips) is -invisible. A user reporting "my library lost samples" leaves nothing to inspect. Direction: -route swallowed errors through `logMain` at minimum. - -**F30 · P3 · Settings writes are not atomic** (`settings.ts:18-20`) — a crash mid-write -truncates `settings.json`; `read()` then silently returns `{}` and the Freesound key vanishes -with no message. Write-temp-then-rename is one line. - -### 2.6 Boundary & safety (Electron posture) - -Overall posture is decent for a local-first app: context isolation on, node integration off, -no remote content in the window, `openExternal` restricted to `https:`, model files whitelisted -(`stems.ts:9,15-22` — tested against traversal in `stems.test.ts:45-48`). Remaining real items: - -**F31 · P1 · `stems:persist` / `stems:getCached` path traversal via `sourceHash`.** -`stems.ts:24-30`: `path.join(userData, 'stems', sourceHash)` with a renderer-supplied string — -`'../../../../Users/x/.ssh'` escapes userData; `persistStems` then `mkdirSync`s and writes -attacker-shaped WAV bytes at the joined path (and `renderClip` output lands there too). Renderer -compromise is required, but this app's CSP deliberately allows `'unsafe-eval'` renderer-wide -(`main/index.ts:233` — `script-src 'self' 'unsafe-eval'`, not scoped to the worker), and the -renderer regularly renders strings from a remote API (Freesound names/tags — React escapes -them today). Defense-in-depth says validate: `/^[0-9a-f]{64}$/`. **CONFIRMED** (traversal is -real; exploitability gated on renderer compromise). Same class, lower stakes: -`freesound:download` fetches **any** renderer-supplied URL with Electron's net stack (SSRF / -arbitrary-content file write into staging), and `packs:export` / `audio:exportRegions` / -`library:importFolder` accept arbitrary absolute paths (write-anywhere / read-tree-anywhere -primitives). Direction: URL host allowlist (`freesound.org`, `cdn.freesound.org`); paths from -dialogs could be brokered by main instead of round-tripping through the renderer. - -**F32 · P2 · The `local-file://` protocol serves the entire filesystem to the renderer, CORS -open.** `main/index.ts:184-222`: any path, no scoping to userData or user-picked roots, plus -`Access-Control-Allow-Origin: *` and `bypassCSP: true`. That is the app's design (library rows -point at arbitrary user files), but combined with F31's threat model it means any renderer-side -script can read any file on disk via `fetch('local-file:///etc/passwd')`. Direction: maintain an -allowed-roots set (userData + imported folders), 403 otherwise. - -**F33 · P2 · Freesound API key handling is as documented but weak.** Plaintext in -`settings.json` (fine, documented in AGENTS.md), but it is re-read from disk on **every search** -(`freesound.ts:8-15`) and sent as a `token` query param (that's Freesound's API design). The -key never leaves the main process — good. Low risk; note only. - -**F34 · P2 · Windows path correctness across the URL bridge.** `toLocalFileUrl` -(`src/utils/index.ts:40-43`) splits on `/` only; a Windows path `C:\Users\me\track.mp3` becomes -`local-file://C%3A%5CUsers%5Cme%5Ctrack.mp3` whose "hostname" the protocol handler -(`main/index.ts:185-190`) reassembles as `/C:\Users\me\track.mp3` — `existsSync` on that form is -at best accidental. `fileNameFromPath` has the same `/`-only assumption. The app ships a Windows -NSIS build; if this breaks, **no local file plays or renders a waveform on Windows** — yet -nothing in CI runs the renderer at all. **PLAUSIBLE** (needs a Windows run; possibly masked if -Chromium normalizes back-slashes in URLs). Direction: normalize `\` → `/` in `toLocalFileUrl` -and add a Windows smoke test. - -### 2.7 Documentation & DX - -**F35 · P2 · README has no build-from-source/contributing section.** `pnpm install` → postinstall -`electron-rebuild` (needs toolchain) → `pnpm dev`; none of it is in the README (only download -links). AGENTS.md covers it implicitly, but that's agent-facing. The -`NODE_MODULE_VERSION` footgun **is** well documented in AGENTS.md (accurate: tests stub electron -and avoid the DB), which is genuinely good. - -**F36 · P2 · Seed script is macOS-only and destructive, and AGENTS.md bakes in the macOS -assumption.** `seed.sh:11-19` only knows `~/Library/Application Support/...`; on Linux -(`~/.config/samplebyte`) and Windows it exits "userData directory not found" even after -`pnpm dev`. AGENTS.md states "userData is always at ~/Library/Application Support/samplebyte/ in -both dev and production builds" — true only on macOS. The seed also `DELETE FROM samples` (the -user's whole dev library) without unlinking materialized WAVs (orphans), and its 1-based -`slot_number`s render shifted (F23). Docs claims vs reality otherwise check out: `pnpm dev`, -`pnpm test`, `pnpm release` flows match `package.json`/`tag.mjs`/workflows exactly, including -the fetch-stem-model-before-build requirement (present in `release.yml:22,40`). - -**F37 · P3 · CI gap: nothing builds or launches the app.** `ci.yml` runs tsc/lint/vitest/audit — -solid — but no `vite build`, no electron-builder dry-run, no renderer test at all (all tests are -main-process services). A broken renderer import ships to a tag before anyone notices; -`release.yml` would then publish it (draft → undrafted automatically). - ---- - -## 3. Design tensions (deepest structural issues) - -**T1 — Three lifetimes, one `file_path` column.** Library rows point at (a) user-owned files -imported in place, (b) app-rendered WAVs in `userData/samples`, (c) cache artifacts -(`stems/`, `staging/`). The schema cannot distinguish them, so every consumer guesses: -`deleteSample` unlinks all three (F1), the stems cache gets corrupted, "staging" becomes -permanent (F21). *Alternative:* an `owned` flag (or path-prefix rule) enforced in one -`deleteSampleFiles()` helper; or copy-on-import so everything under the library is app-owned — -simpler invariant, more disk. - -**T2 — Projection consistency by call-sequence, not by the data layer.** ADR-0006's "library is -a live projection" is implemented as "every writer remembers to call -`syncProjectChopsToLibrary` and no two calls overlap". Neither holds (F3, F4), and staleness is -decided by comparing wall-clock timestamps across tables (`isChopSampleStale`). *Alternative:* -serialize sync per project behind a queue in main, add UNIQUE(source_chop_id), and derive -staleness from a monotonic per-chop revision instead of `Date.now()` pairs. - -**T3 — Durable state lives in the waveform widget.** Chop identity is the WaveSurfer region id; -names live in a React state map keyed by those ids; a trim rebuilds regions **without ids** -(`remapRegionsForTrim` drops them) so every trim rewrites all chop rows, re-renders all library -WAVs, and orphans every pack-slot chop reference — maximal churn for a metadata-preserving -operation. Zero-region states are indistinguishable from "editor not ready" (F5). -*Alternative:* the DB row is the chop; the region is a view of it (id round-trips through -trim; empty is a valid saved state). - -**T4 — The hardware-profile abstraction is half real.** README: "adding a new hardware target is -just one config object". In reality a profile's `bitDepth` is ignored, its `sampleFmt` is passed -unvalidated into ffmpeg (F2), its `padCount` is ignored by the grid/UI (F19b), its filename -convention can self-collide (F6), and the renderer keeps its own profile list (F19a). A new -target added "as one config object" today would silently inherit all of this. *Alternative:* -make profiles the single source (serve via the existing dead `packs:getProfiles`), map -bit depth → codec in one place, test each profile's render. - -**T5 — Trusted-renderer IPC in an unsafe-eval renderer.** The bridge is beautifully typed but -validates nothing at runtime; simultaneously the CSP grants `'unsafe-eval'` to the whole -renderer for the sake of one worker, and `local-file://` serves the disk CORS-open (F31, F32). -Each choice is individually defensible; together they mean one renderer bug = full disk -read/write. *Alternative:* validate at the boundary (hash regex, URL allowlist, path roots) — -cheap — and scope eval to the worker (serve the worker script with its own CSP header, or ship -demucs as a real module). - ---- - -## 4. Expectation gaps ("I expected X, found Y") - -- Expected deleting a library entry to remove a row; found it deletes the user's original file - on disk (F1). -- Expected the advertised MPC/Generic 24-bit profiles to export; found an invalid ffmpeg flag - that fails every render (F2) — and a UI that reports failure as success (F7). -- Expected autosave to be crash-safe and idempotent; found duplicate projects under fast editing - (F3), duplicate samples under overlapping syncs (F4), and delete-all edits that never persist - (F5). -- Expected "packs are independent snapshots" to mean pads survive library deletion; found - deleteSample kills the pads (dialog does warn) while a sibling code path keeps them (F22), and - pad audition depends on the live source anyway (F13). -- Expected the auto-updater to update; found an unreachable IPC surface — no user ever gets an - update prompt (F18). -- Expected "Import to Library" on Freesound to import the sound; found it stages a 128 kbps - preview and opens the editor, storing no license/attribution (F24). -- Expected `userData/sources`, `staging` and "cache" to be reclaimable; found they are permanent, - growing, and load-bearing (F21). -- Expected the typed IPC contract to imply validated inputs; found path traversal in - `stems:persist` and fetch-any-URL in `freesound:download` (F31). -- Expected `pnpm seed` (per AGENTS.md) to work after `pnpm dev`; found it is macOS-only and - wipes the dev library (F36). -- Expected the test suite ("audio-rendering modules covered against real ffmpeg") to cover the - shipped profiles; found only `s16` is ever rendered (F2, F37). - ---- - -## 5. Open questions - -1. Is in-place import (no copy) a deliberate product decision? It drives F1/T1; a one-line answer - changes the right fix. -2. Is the Freesound preview-only download understood/accepted (vs. OAuth2 for originals), and is - license attribution intentionally out of scope for exported packs? -3. Was the auto-update UI removed deliberately (0.0.x cadence via manual downloads), or lost in - the refactor? `electron-updater` is still shipped either way. -4. Windows: has anyone run the packaged build end-to-end? (F34 would be immediately visible; the - README documents the SmartScreen flow, which suggests Windows is a real target.) -5. `pitch_shift_semitones` / `time_stretch_ratio` — roadmap items or abandoned? They shape the - slot schema and seed script today. -6. Generic profile `padCount: 128` — is >16-pad export intended soon? It determines whether the - hardcoded 16s (grid, send-to-pack slice, recovery scan) are bugs or ceiling. -7. The `.harness/` ADRs are encrypted in-repo; should QA artifacts like this one be committed - through the doctier filter (this environment has no filter configured, so this file is stored - as written)? - ---- - -## Appendix — strengths worth keeping - -Not everything is adversarial: the typed IPC contract (`ipc-contract.ts`) with compile-time -channel/signature agreement is excellent; the render funnel (`renderClip` as the single ffmpeg -choke point) is the right shape (it just needs profile validation); the pad-owned-audio snapshot -model is a genuinely good design (enforce it in deletion and audition paths); virtualized -library/source lists, the analysis worker pool with transfer semantics, the electron test stub -approach, and the honest, current AGENTS.md are all above average for a solo side project. +-----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-----