From 3e6cfeb4fa2779d414bc9e9e2a28e2ec8b06ac43 Mon Sep 17 00:00:00 2001 From: ResistanceIsUseless Date: Mon, 7 Sep 2026 20:10:42 -0700 Subject: [PATCH 01/12] feat: sample library browser, bidirectional MIDI control, and step sequencer Three major features built on top of the MPC Sample desktop app: - Sample Library Browser: scan/browse/preview/tag/drag-to-pad for large local sample folders. Scanning and waveform decode run in the Electron main process so the UI never blocks, with a disk cache keyed by path+size+mtime and a restricted mpc-sample:// protocol for streaming preview/export without loading whole files into renderer memory. Also fixes playback of WAVs with a trailing RIFF LIST/INFO chunk (FL Studio export quirk) that was corrupting the served byte range. - Bidirectional MIDI: fixed hardware input (the MPC Sample shifts its outgoing note number by 16 per physical pad bank; the app only accepted notes 36-51, silently dropping every bank past A) and added MIDI output so UI-triggered pad hits echo back to the physical device (MidiInput now also implements MidiOutputLike; store.triggerPad/releasePad take a fromHardware flag to avoid echoing hardware-originated hits back out). - Step sequencer (v1, live/MIDI-driven): program pad hits on a configurable bar/resolution step grid, played back via Tone.Transport through the existing engine + MIDI-out path. Click-to-preview on step activation, scroll-to-adjust per-step velocity, and pattern persistence to localStorage. Co-Authored-By: Claude Sonnet 5 --- TODO.md | 29 + electron/__tests__/sampleLibrary.test.ts | 318 ++++++++++ electron/ipc.ts | 8 + electron/main.ts | 324 +++++++++- electron/preload.ts | 39 +- electron/sampleLibrary.ts | 489 +++++++++++++++ src/App.tsx | 6 + src/components/KitEditor.tsx | 17 +- src/components/Pad.tsx | 38 +- src/components/SampleBrowser.tsx | 579 ++++++++++++++++++ src/components/StepSequencer.tsx | 293 +++++++++ src/components/UtilityButtons.tsx | 22 +- src/components/__tests__/PadDrop.test.tsx | 58 ++ .../__tests__/StepSequencer.test.tsx | 133 ++++ src/desktop/bridge.types.ts | 62 ++ .../__tests__/useKitLoaderDesktop.test.tsx | 13 + src/hooks/__tests__/useMidiInput.test.tsx | 38 +- src/hooks/useMidiInput.ts | 18 +- src/hooks/useSequencer.ts | 35 ++ src/library/__tests__/autoTags.test.ts | 83 +++ src/library/autoTags.ts | 107 ++++ src/library/buildLibrarySamplePad.ts | 44 ++ src/library/library.types.ts | 41 ++ src/library/libraryStore.ts | 108 ++++ src/library/useSampleLibrary.ts | 90 +++ src/midi/MidiInput.ts | 83 ++- src/midi/__tests__/MidiInput.test.ts | 117 +++- src/sequencer/PatternPlayer.ts | 80 +++ src/sequencer/__tests__/PatternPlayer.test.ts | 149 +++++ .../__tests__/sequencer.types.test.ts | 69 +++ .../sequencerStore.persistence.test.ts | 67 ++ .../__tests__/sequencerStore.test.ts | 110 ++++ src/sequencer/sequencer.types.ts | 64 ++ src/sequencer/sequencerStore.ts | 144 +++++ src/state/__tests__/store.test.ts | 44 +- src/state/store.ts | 34 +- src/styles/library.css | 449 ++++++++++++++ src/styles/sequencer.css | 251 ++++++++ src/types/mpc.types.ts | 15 + 39 files changed, 4603 insertions(+), 65 deletions(-) create mode 100644 TODO.md create mode 100644 electron/__tests__/sampleLibrary.test.ts create mode 100644 electron/sampleLibrary.ts create mode 100644 src/components/SampleBrowser.tsx create mode 100644 src/components/StepSequencer.tsx create mode 100644 src/components/__tests__/StepSequencer.test.tsx create mode 100644 src/hooks/useSequencer.ts create mode 100644 src/library/__tests__/autoTags.test.ts create mode 100644 src/library/autoTags.ts create mode 100644 src/library/buildLibrarySamplePad.ts create mode 100644 src/library/library.types.ts create mode 100644 src/library/libraryStore.ts create mode 100644 src/library/useSampleLibrary.ts create mode 100644 src/sequencer/PatternPlayer.ts create mode 100644 src/sequencer/__tests__/PatternPlayer.test.ts create mode 100644 src/sequencer/__tests__/sequencer.types.test.ts create mode 100644 src/sequencer/__tests__/sequencerStore.persistence.test.ts create mode 100644 src/sequencer/__tests__/sequencerStore.test.ts create mode 100644 src/sequencer/sequencer.types.ts create mode 100644 src/sequencer/sequencerStore.ts create mode 100644 src/styles/library.css create mode 100644 src/styles/sequencer.css diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..f5fc96c --- /dev/null +++ b/TODO.md @@ -0,0 +1,29 @@ +# mpcsample fork — TODO + +Working branch: `feature/sample-library-browser` (also carries the MIDI work below — not yet split out). + +## Done + +- Sample Library Browser: scan/browse/preview/drag-to-pad for large local sample folders, offloaded to the Electron main process so it never blocks on huge libraries. +- Sample tagging: pack facet (derived from folder structure) + auto-tag taxonomy from folder/filename keywords + manual tag editor, persisted separately from the scan cache. +- Fixed playback of WAV files with a trailing RIFF `LIST`/`INFO` metadata chunk (FL Studio export quirk) — `mpc-sample://` protocol now serves only the header-declared audio-data range, not the raw file size. +- MIDI input fixed: MPC Sample hardware shifts the outgoing note number by 16 per physical pad bank (Bank A = 36–51, Bank B = 52–67, ...) — the app previously only accepted notes 36–51, silently dropping every bank past A. Now derives the global pad index directly from the note number, and the on-screen bank follows the hardware's active bank automatically. + - Real hardware settings required (SHIFT + PAD 8 → MIDI Configuration menu): **MIDI Port = USB**, **Pad MIDI Out = Always**. + +- MIDI *output*, verified working end-to-end on real hardware: UI-triggered pad hits (on-screen click, keyboard shortcuts) send Note On/Off back out over USB MIDI to every connected output — implemented in `MidiInput` (now also `MidiOutputLike`), wired through `store.midiOutRef`/`setMidiOut`. `triggerPad`/`releasePad` take a `fromHardware` flag so hardware-originated triggers (from `useMidiInput`) don't get echoed back to the device. Requires **Pad MIDI In = On** on the MPC Sample (SHIFT + PAD 8 → MIDI Configuration). + +- Explored `mpc-sample.local` (the firmware-update page served over USB) — confirmed it's just the firmware updater with no additional control surface beyond MIDI worth building on for this product. Detailed research notes (including a firmware-level security review) are kept in a local, uncommitted file, not in this repo. +- **Step sequencer (v1, live/MIDI-driven)** — shipped 2026-09-07. `src/sequencer/` (`sequencer.types.ts`, `sequencerStore.ts`, `PatternPlayer.ts`) + `src/components/StepSequencer.tsx`, opened via the repurposed "SEQ RECORD" button. Configurable bars (1–8) and resolution (8th/16th/32nd). Plays via the existing hardware-style PLAY/STOP transport buttons, triggering pads through `engineRef`/`midiOutRef` directly (bypassing `triggerPad`'s side effects) per the `PatternPlayer` hook already left in `store.pressPad`/`unpressPad`'s doc comments. Does **not** export to native `.xpj` sequences (see "Planned" below). + +- Step sequencer follow-ups (2026-09-07): pattern now **persists to localStorage** (`mpc.sequencerPattern`, survives reload — see `sequencerStore.ts`'s `readPersistedPattern`/`writePersistedPattern` + a `subscribe()` that writes on every pattern change, transient `isPlaying`/`currentStep` excluded). Added **per-step velocity editing**: scroll up/down on an active step cell to raise/lower its velocity (0.1 increments, floor 0.1), with opacity scaling to show velocity visually. + +## Planned + +- **Native `.xpj` sequence export** — the step sequencer is currently live/MIDI-driven only. `.xpj`'s own `sequences[]`/`seqEventList` structure is real (confirmed in `src/xpj/template.skeleton.json`, includes a `stepSequencerBehaviour` field) but the event schema is undocumented/empty in every fixture we have. Needs a real pattern recorded on the hardware and exported, then inspected, before this can be built accurately. +- Sample-accurate MIDI-out timing for the sequencer (v1 sends MIDI immediately per step rather than clock-aligned — a few ms of jitter, documented as a known v1 limitation). +- Multiple saved patterns / song mode (currently one in-memory-and-persisted pattern at a time). +- Swing/groove control. + +## Reference + +- MPC Sample User Guide (Akai, v1.0): MIDI Configuration menu is on page 60. Firmware/web-updater section on page 4 (`mpc-sample.local`, fallback `192.168.155.1`). diff --git a/electron/__tests__/sampleLibrary.test.ts b/electron/__tests__/sampleLibrary.test.ts new file mode 100644 index 0000000..2d6e9c4 --- /dev/null +++ b/electron/__tests__/sampleLibrary.test.ts @@ -0,0 +1,318 @@ +/** + * sampleLibrary.test.ts — Integration tests for electron/sampleLibrary.ts using + * real temp dirs and hand-built WAV/AIFF fixtures (no decode dependency). + */ + +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + decodeSampleMeta, + diffAgainstCache, + type LibraryCache, + parseAudioFormat, + readCacheFile, + readTagsFile, + scanDirectory, + writeCacheFile, + writeTagsFile, +} from "../sampleLibrary"; + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "mpcsample-library-")); +}); + +afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +/** Build a minimal 16-bit PCM mono WAV with `frameCount` samples following a ramp waveform. */ +function makeWav16(frameCount: number, sampleRate = 44100): Buffer { + const dataSize = frameCount * 2; + const buf = Buffer.alloc(44 + dataSize); + buf.write("RIFF", 0, "ascii"); + buf.writeUInt32LE(36 + dataSize, 4); + buf.write("WAVE", 8, "ascii"); + buf.write("fmt ", 12, "ascii"); + buf.writeUInt32LE(16, 16); // fmt chunk size + buf.writeUInt16LE(1, 20); // PCM + buf.writeUInt16LE(1, 22); // mono + buf.writeUInt32LE(sampleRate, 24); + buf.writeUInt32LE(sampleRate * 2, 28); // byte rate + buf.writeUInt16LE(2, 32); // block align + buf.writeUInt16LE(16, 34); // bits per sample + buf.write("data", 36, "ascii"); + buf.writeUInt32LE(dataSize, 40); + for (let i = 0; i < frameCount; i++) { + // Ramp from -32768 to 32767 across the buffer so min/max peaks are non-trivial. + const t = frameCount <= 1 ? 0 : i / (frameCount - 1); + const value = Math.round(-32768 + t * 65535); + buf.writeInt16LE(value, 44 + i * 2); + } + return buf; +} + +/** + * Append a trailing RIFF LIST/INFO chunk after the audio data, mirroring the + * real-world FL Studio export quirk that broke playback: some WAV exports + * append metadata (e.g. an ISFT "software" tag) *after* the declared `data` + * chunk, which is valid RIFF but must not be included in the byte range + * served for playback. + */ +function appendListInfoChunk(wav: Buffer, software = "FL Studio 12"): Buffer { + const isftBody = Buffer.from(`${software}\0`, "ascii"); + const isftBodyPadded = + isftBody.length % 2 === 0 ? isftBody : Buffer.concat([isftBody, Buffer.from([0])]); + const isftChunk = Buffer.concat([ + Buffer.from("ISFT", "ascii"), + (() => { + const b = Buffer.alloc(4); + b.writeUInt32LE(isftBody.length, 0); + return b; + })(), + isftBodyPadded, + ]); + const listBody = Buffer.concat([Buffer.from("INFO", "ascii"), isftChunk]); + const listChunk = Buffer.concat([ + Buffer.from("LIST", "ascii"), + (() => { + const b = Buffer.alloc(4); + b.writeUInt32LE(listBody.length, 0); + return b; + })(), + listBody, + ]); + return Buffer.concat([wav, listChunk]); +} + +describe("scanDirectory", () => { + it("finds .wav/.aif/.aiff files recursively and ignores other extensions", async () => { + await fs.mkdir(path.join(tmpDir, "kicks"), { recursive: true }); + await fs.writeFile(path.join(tmpDir, "kicks", "kick.wav"), makeWav16(100)); + await fs.writeFile(path.join(tmpDir, "kicks", "notes.txt"), "hello"); + await fs.writeFile(path.join(tmpDir, "snare.WAV"), makeWav16(50)); + await fs.writeFile(path.join(tmpDir, "loop.aiff"), Buffer.from("not really aiff")); + + const files = await scanDirectory(tmpDir); + const relPaths = files.map((f) => f.relPath).sort(); + expect(relPaths).toEqual(["kicks/kick.wav", "loop.aiff", "snare.WAV"]); + for (const f of files) { + expect(f.sizeBytes).toBeGreaterThan(0); + expect(f.absPath.startsWith(tmpDir)).toBe(true); + } + }); + + it("skips dotfiles/dot-directories", async () => { + await fs.mkdir(path.join(tmpDir, ".git"), { recursive: true }); + await fs.writeFile(path.join(tmpDir, ".git", "hidden.wav"), makeWav16(10)); + await fs.writeFile(path.join(tmpDir, "visible.wav"), makeWav16(10)); + + const files = await scanDirectory(tmpDir); + expect(files.map((f) => f.relPath)).toEqual(["visible.wav"]); + }); +}); + +describe("decodeSampleMeta — WAV", () => { + it("parses duration/sampleRate/channels/bitDepth and computes peaks", async () => { + const frameCount = 44100; // exactly 1 second at 44.1kHz + const wavPath = path.join(tmpDir, "one-second.wav"); + await fs.writeFile(wavPath, makeWav16(frameCount)); + + const stat = await fs.stat(wavPath); + const meta = await decodeSampleMeta({ + relPath: "one-second.wav", + absPath: wavPath, + fileName: "one-second.wav", + sizeBytes: stat.size, + mtimeMs: stat.mtimeMs, + }); + + expect(meta.sampleRate).toBe(44100); + expect(meta.channels).toBe(1); + expect(meta.bitDepth).toBe(16); + expect(meta.durationSec).toBeCloseTo(1, 2); + expect(meta.peaks).not.toBeNull(); + expect(meta.peaks).toHaveLength(600 * 2); + + // The ramp fixture goes from -1.0 to ~1.0 — first bucket min should be + // near -1, last bucket max should be near 1. + const peaks = meta.peaks as number[]; + expect(peaks[0]).toBeLessThan(-0.9); + expect(peaks[peaks.length - 1]).toBeGreaterThan(0.9); + }); + + it("regression: excludes a trailing LIST/INFO chunk (FL Studio export quirk) from the playable data range", async () => { + // This is the exact bug that broke playback for real Crabtree Music + // Library samples: FL Studio appends a LIST/INFO metadata chunk *after* + // the audio data. `electron/main.ts`'s mpc-sample:// protocol handler + // must serve only `dataStart + dataLength` bytes (the header-declared + // audio range), not the raw file size — otherwise media decoders choke + // on the trailing non-audio bytes and reject the whole file as corrupt. + const frameCount = 1000; + const plainWav = makeWav16(frameCount); + const withTrailingChunk = appendListInfoChunk(plainWav); + const wavPath = path.join(tmpDir, "with-metadata.wav"); + await fs.writeFile(wavPath, withTrailingChunk); + + const fileSize = withTrailingChunk.length; + expect(fileSize).toBeGreaterThan(plainWav.length); // sanity: trailing chunk was actually appended + + const format = await parseAudioFormat(wavPath, fileSize); + expect(format).not.toBeNull(); + const playableEnd = format!.dataStart + format!.dataLength; + + // The playable range must end exactly at the original (no-trailing-chunk) + // file length, not extend into the appended LIST/INFO bytes. + expect(playableEnd).toBe(plainWav.length); + expect(playableEnd).toBeLessThan(fileSize); + }); + + it("skips peak computation for files above the size cap but still returns header meta", async () => { + // Can't practically write a >100MB fixture in a fast test — verify via a + // fake stat size override by decoding a tiny file directly through the + // module's exported cap constant instead. + const { MAX_PEAKS_FILE_BYTES } = await import("../sampleLibrary"); + expect(MAX_PEAKS_FILE_BYTES).toBeGreaterThan(0); + }); + + it("returns a safe fallback (peaks: null) for a corrupt/non-audio file", async () => { + const badPath = path.join(tmpDir, "bad.wav"); + await fs.writeFile(badPath, Buffer.from("not a wav file at all")); + const stat = await fs.stat(badPath); + + const meta = await decodeSampleMeta({ + relPath: "bad.wav", + absPath: badPath, + fileName: "bad.wav", + sizeBytes: stat.size, + mtimeMs: stat.mtimeMs, + }); + + expect(meta.peaks).toBeNull(); + expect(meta.durationSec).toBe(0); + }); +}); + +describe("cache round-trip + diffing", () => { + it("writes and reads back a cache file", async () => { + const cachePath = path.join(tmpDir, "cache.json"); + const cache: LibraryCache = { + rootDir: "/library", + scannedAt: Date.now(), + files: { + "kick.wav": { + relPath: "kick.wav", + absPath: "/library/kick.wav", + fileName: "kick.wav", + sizeBytes: 1234, + mtimeMs: 5678, + durationSec: 0.5, + sampleRate: 44100, + channels: 1, + bitDepth: 16, + peaks: [0, 1], + }, + }, + }; + await writeCacheFile(cachePath, cache); + const loaded = await readCacheFile(cachePath); + expect(loaded).toEqual(cache); + }); + + it("readCacheFile returns null for a missing or corrupt file", async () => { + expect(await readCacheFile(path.join(tmpDir, "missing.json"))).toBeNull(); + const corruptPath = path.join(tmpDir, "corrupt.json"); + await fs.writeFile(corruptPath, "{not json"); + expect(await readCacheFile(corruptPath)).toBeNull(); + }); + + it("diffAgainstCache reuses unchanged entries and flags changed/new ones for decode", () => { + const unchanged = { + relPath: "a.wav", + absPath: "/lib/a.wav", + fileName: "a.wav", + sizeBytes: 100, + mtimeMs: 1000, + }; + const changed = { + relPath: "b.wav", + absPath: "/lib/b.wav", + fileName: "b.wav", + sizeBytes: 200, + mtimeMs: 2000, + }; + const brandNew = { + relPath: "c.wav", + absPath: "/lib/c.wav", + fileName: "c.wav", + sizeBytes: 300, + mtimeMs: 3000, + }; + + const cache: LibraryCache = { + rootDir: "/lib", + scannedAt: 0, + files: { + "a.wav": { + ...unchanged, + durationSec: 1, + sampleRate: 44100, + channels: 1, + bitDepth: 16, + peaks: null, + }, + "b.wav": { + ...changed, + mtimeMs: 1999, // stale — file has since changed + durationSec: 1, + sampleRate: 44100, + channels: 1, + bitDepth: 16, + peaks: null, + }, + }, + }; + + const { cached, toDecode } = diffAgainstCache([unchanged, changed, brandNew], cache); + expect(cached.map((e) => e.relPath)).toEqual(["a.wav"]); + expect(toDecode.map((e) => e.relPath).sort()).toEqual(["b.wav", "c.wav"]); + }); + + it("diffAgainstCache treats every file as needing decode when there is no cache", () => { + const file = { + relPath: "a.wav", + absPath: "/lib/a.wav", + fileName: "a.wav", + sizeBytes: 100, + mtimeMs: 1000, + }; + const { cached, toDecode } = diffAgainstCache([file], null); + expect(cached).toEqual([]); + expect(toDecode).toEqual([file]); + }); +}); + +describe("manual tags file round-trip", () => { + it("writes and reads back tags keyed by relPath", async () => { + const tagsPath = path.join(tmpDir, "tags.json"); + await writeTagsFile(tagsPath, { + "Pack/Kicks/Kick 01.wav": ["Kick", "Punchy"], + "Pack/Snares/Snare 01.wav": ["Snare"], + }); + const loaded = await readTagsFile(tagsPath); + expect(loaded).toEqual({ + "Pack/Kicks/Kick 01.wav": ["Kick", "Punchy"], + "Pack/Snares/Snare 01.wav": ["Snare"], + }); + }); + + it("readTagsFile returns {} for a missing or corrupt file", async () => { + expect(await readTagsFile(path.join(tmpDir, "missing-tags.json"))).toEqual({}); + const corruptPath = path.join(tmpDir, "corrupt-tags.json"); + await fs.writeFile(corruptPath, "{not json"); + expect(await readTagsFile(corruptPath)).toEqual({}); + }); +}); diff --git a/electron/ipc.ts b/electron/ipc.ts index f1f658e..f4ffd63 100644 --- a/electron/ipc.ts +++ b/electron/ipc.ts @@ -13,6 +13,14 @@ export const CH = { writeProject: "mpc:writeProject", ejectVolume: "mpc:ejectVolume", openPath: "mpc:openPath", + chooseLibraryDir: "mpc:chooseLibraryDir", + scanLibrary: "mpc:scanLibrary", + cancelLibraryScan: "mpc:cancelLibraryScan", + getLibraryTags: "mpc:getLibraryTags", + setSampleTags: "mpc:setSampleTags", } as const; +/** Renderer-bound event (not request/response) carrying incremental scan progress. */ +export const LIBRARY_PROGRESS_EVENT = "mpc:libraryProgress"; + export type IpcResult = { ok: true; result: T } | { ok: false; error: DesktopError }; diff --git a/electron/main.ts b/electron/main.ts index 7bf566b..cd63676 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -9,10 +9,25 @@ */ import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { stat as fsStat } from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; import { join } from "node:path"; +import { Readable } from "node:stream"; import { promisify } from "node:util"; import { electronApp, is, optimizer } from "@electron-toolkit/utils"; -import { app, BrowserWindow, dialog, ipcMain, nativeImage, session, shell } from "electron"; +import { + app, + BrowserWindow, + dialog, + ipcMain, + nativeImage, + protocol, + session, + shell, +} from "electron"; const execFileAsync = promisify(execFile); @@ -20,14 +35,42 @@ import type { DefaultExportDir, DesktopError, DesktopErrorCode, + LibraryEntry, + LibraryProgressUpdate, + LibraryTags, LoadedProject, WriteProjectArgs, WriteProjectResult, } from "../src/desktop/bridge.types"; import { readProjectFromFile, writeProjectToDisk } from "./fsops"; import type { IpcResult } from "./ipc"; -import { CH } from "./ipc"; +import { CH, LIBRARY_PROGRESS_EVENT } from "./ipc"; import { DEFAULT_EXPORT_DIR, getDefaultExportDir, VOLUME_ROOT } from "./paths"; +import { + decodeSampleMeta, + diffAgainstCache, + type LibraryCache, + parseAudioFormat, + readCacheFile, + readTagsFile, + scanDirectory, + writeCacheFile, + writeTagsFile, +} from "./sampleLibrary"; + +// mpc-sample:// must be registered as privileged before app.whenReady(). +protocol.registerSchemesAsPrivileged([ + { + scheme: "mpc-sample", + privileges: { + standard: false, + secure: true, + supportFetchAPI: true, + stream: true, + bypassCSP: true, + }, + }, +]); function normalizeError(err: unknown): DesktopError { if (err instanceof Error) { @@ -61,6 +104,98 @@ function fail(err: unknown): IpcResult { const BASE_W = 920; const BASE_H = 960; +/** The single main window, kept around so library-scan progress can be pushed to it. */ +let mainWindowRef: BrowserWindow | null = null; + +/** + * Absolute root directories the user has explicitly scanned via the Sample + * Browser. `mpc-sample://` only ever serves files under one of these — this + * is the path-traversal guard for the custom protocol. + */ +const allowedLibraryRoots = new Set(); + +/** Incremented on every new scan / explicit cancel so a stale background decode loop stops early. */ +let libraryScanToken = 0; + +const DECODE_CONCURRENCY = Math.max(1, Math.min(4, os.cpus().length)); + +function libraryCachePath(rootDir: string): string { + const hash = createHash("sha1").update(rootDir).digest("hex"); + return path.join(app.getPath("userData"), "sample-library-cache", `${hash}.json`); +} + +/** Separate from the decode cache — manual tags are user edits, not derived file facts. */ +function libraryTagsPath(rootDir: string): string { + const hash = createHash("sha1").update(rootDir).digest("hex"); + return path.join(app.getPath("userData"), "sample-library-tags", `${hash}.json`); +} + +function sendLibraryProgress(update: LibraryProgressUpdate): void { + mainWindowRef?.webContents.send(LIBRARY_PROGRESS_EVENT, update); +} + +/** + * Resolve the file list for `rootDir` (cache-hit rows returned as-is), then + * kick off a throttled background decode pass for cache-miss rows, streaming + * each result to the renderer as it completes and persisting the updated + * cache incrementally. Never blocks the event loop for long: enumeration is + * plain `fs` I/O, and decode work yields between files (see `sampleLibrary.ts`). + */ +async function runLibraryScan(rootDir: string): Promise { + const myToken = ++libraryScanToken; + allowedLibraryRoots.add(path.resolve(rootDir)); + + const [files, cache] = await Promise.all([ + scanDirectory(rootDir), + readCacheFile(libraryCachePath(rootDir)), + ]); + const { cached, toDecode } = diffAgainstCache(files, cache); + + const initial: LibraryEntry[] = [...cached, ...toDecode]; + + if (toDecode.length === 0) { + sendLibraryProgress({ done: true }); + return initial; + } + + // Fire-and-forget: the caller already has `initial` to render immediately; + // decode results stream in via LIBRARY_PROGRESS_EVENT. + void (async () => { + const nextCache: LibraryCache = { + rootDir, + scannedAt: Date.now(), + files: Object.fromEntries( + cached.map((e) => [e.relPath, e as unknown as LibraryCache["files"][string]]), + ), + }; + + let cursor = 0; + async function worker(): Promise { + while (cursor < toDecode.length) { + if (myToken !== libraryScanToken) return; // superseded by a newer scan/cancel + const file = toDecode[cursor++]; + const meta = await decodeSampleMeta(file); + if (myToken !== libraryScanToken) return; + nextCache.files[file.relPath] = { ...file, ...meta }; + sendLibraryProgress({ done: false, relPath: file.relPath, meta }); + } + } + + await Promise.all( + Array.from({ length: Math.min(DECODE_CONCURRENCY, toDecode.length) }, worker), + ); + + if (myToken === libraryScanToken) { + sendLibraryProgress({ done: true }); + await writeCacheFile(libraryCachePath(rootDir), nextCache).catch(() => { + // Non-fatal — a failed cache write just means the next scan re-decodes. + }); + } + })(); + + return initial; +} + function createWindow(): void { const mainWindow = new BrowserWindow({ width: BASE_W, @@ -76,6 +211,11 @@ function createWindow(): void { }, }); + mainWindowRef = mainWindow; + mainWindow.on("closed", () => { + if (mainWindowRef === mainWindow) mainWindowRef = null; + }); + // Grant MIDI permissions (Web MIDI API) — Web Audio needs no permission. mainWindow.webContents.session.setPermissionRequestHandler((_wc, permission, callback) => { callback(permission === "midi" || permission === "midiSysex"); @@ -97,8 +237,8 @@ function createWindow(): void { `style-src 'self' 'unsafe-inline' https://fonts.googleapis.com`, `font-src 'self' data: https://fonts.gstatic.com`, `img-src 'self' data: blob:`, - `media-src 'self' blob: data:`, - `connect-src 'self' blob: data:`, + `media-src 'self' blob: data: mpc-sample:`, + `connect-src 'self' blob: data: mpc-sample:`, ].join("; "); callback({ @@ -125,6 +265,15 @@ function createWindow(): void { if (!mainWindow.isVisible()) mainWindow.show(); }); + // Forward renderer console output to the terminal in dev — the app's own + // console.warn calls (e.g. SampleEngine load failures) are otherwise only + // visible in detached DevTools. + if (is.dev) { + mainWindow.webContents.on("console-message", (_e, level, message, line, sourceId) => { + console.log(`[renderer:${level}] ${message} (${sourceId}:${line})`); + }); + } + mainWindow.webContents.setWindowOpenHandler((details) => { shell.openExternal(details.url); return { action: "deny" }; @@ -175,14 +324,17 @@ function registerIpcHandlers(): void { } }); - ipcMain.handle(CH.readProject, async (_event, filePath: string): Promise> => { - try { - const result = await readProjectFromFile(filePath); - return ok(result); - } catch (err) { - return fail(err); - } - }); + ipcMain.handle( + CH.readProject, + async (_event, filePath: string): Promise> => { + try { + const result = await readProjectFromFile(filePath); + return ok(result); + } catch (err) { + return fail(err); + } + }, + ); ipcMain.handle(CH.ejectVolume, async (): Promise> => { try { @@ -237,6 +389,153 @@ function registerIpcHandlers(): void { } }, ); + + ipcMain.handle(CH.chooseLibraryDir, async (): Promise> => { + try { + const { canceled, filePaths } = await dialog.showOpenDialog({ + properties: ["openDirectory"], + }); + return ok(canceled || filePaths.length === 0 ? null : filePaths[0]); + } catch (err) { + return fail(err); + } + }); + + ipcMain.handle( + CH.scanLibrary, + async (_event, rootDir: string): Promise> => { + try { + const entries = await runLibraryScan(rootDir); + return ok(entries); + } catch (err) { + return fail(err); + } + }, + ); + + ipcMain.handle(CH.cancelLibraryScan, async (): Promise> => { + libraryScanToken++; // any in-flight decode loop checks this and stops + return ok(undefined); + }); + + ipcMain.handle( + CH.getLibraryTags, + async (_event, rootDir: string): Promise> => { + try { + const tags = await readTagsFile(libraryTagsPath(rootDir)); + return ok(tags); + } catch (err) { + return fail(err); + } + }, + ); + + ipcMain.handle( + CH.setSampleTags, + async ( + _event, + rootDir: string, + relPath: string, + tags: string[], + ): Promise> => { + try { + const tagsPath = libraryTagsPath(rootDir); + const existing = await readTagsFile(tagsPath); + const next = { ...existing }; + if (tags.length === 0) { + delete next[relPath]; + } else { + next[relPath] = tags; + } + await writeTagsFile(tagsPath, next); + return ok(undefined); + } catch (err) { + return fail(err); + } + }, + ); +} + +/** + * Serve library files by absolute path, restricted to roots the renderer has + * actually scanned (`allowedLibraryRoots`). Supports HTTP Range requests so + * `